@agfpd/iapeer-memory 0.2.2 → 0.2.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.2.2",
3
+ "version": "0.2.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.2.2"
30
+ "@agfpd/iapeer-memory-core": "0.2.4"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/bun": "^1.2.0",
package/src/cli.ts CHANGED
@@ -24,6 +24,7 @@ import { cmdInit } from "./commands/init.js";
24
24
  import { cmdInstallBinary } from "./commands/install-binary.js";
25
25
  import { cmdMemoryd } from "./commands/memoryd.js";
26
26
  import { cmdMigrate } from "./commands/migrate.js";
27
+ import { cmdDreamPaths } from "./commands/dream-paths.js";
27
28
  import { cmdProvisionPeer, cmdUnprovisionPeer } from "./commands/provision-peer.js";
28
29
  import { cmdRender } from "./commands/render.js";
29
30
  import { cmdStatus } from "./commands/status.js";
@@ -52,14 +53,19 @@ Commands:
52
53
  init step / repair path; needs package sources
53
54
  provision-peer --cwd P --runtime claude|codex --personality NAME [--occasion O]
54
55
  merge the direct session surfaces into one peer's
55
- cwd (claude: hooks/MCP/skills; codex: project MCP;
56
- idempotent, own keys only); the iapeer core shells
57
- into this at peer birth
56
+ cwd (claude: hooks/MCP/skills; codex: project MCP +
57
+ hooks.json with a trust-hooks pre-seed; idempotent,
58
+ own keys only); the iapeer core shells into this at
59
+ peer birth
58
60
  unprovision-peer --cwd P --runtime claude|codex [--occasion O]
59
61
  strip OUR surfaces from one peer's cwd (mirror)
60
62
  fm-update [ops] FILE... structural frontmatter edits + attribution stamp
61
63
  migrate --source DIR move harness auto-memory into the vault
62
64
  (dry-run by default; --apply to execute)
65
+ dream-paths tick-time DreamWeaver fan-out resolution: agent
66
+ memory folders + transcript globs per runtime
67
+ from the LIVE registry (the Index shells this on
68
+ DREAM_TICK; read-only)
63
69
  render index|fragment|doctrine|guide
64
70
  render one artifact explicitly (memoryd does this
65
71
  continuously; render is the manual/scripted path)
@@ -124,8 +130,10 @@ export async function main(argv: string[]): Promise<number> {
124
130
  return cmdUpdate(rest, egress);
125
131
  case "install-binary":
126
132
  return cmdInstallBinary(rest, egress);
133
+ case "dream-paths":
134
+ return cmdDreamPaths(rest, egress);
127
135
  case "provision-peer":
128
- return cmdProvisionPeer(rest);
136
+ return cmdProvisionPeer(rest, egress);
129
137
  case "unprovision-peer":
130
138
  return cmdUnprovisionPeer(rest);
131
139
  case "fm-update":
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `iapeer-memory dream-paths [--iapeer-bin P]` — tick-time resolution of the
3
+ * DreamWeaver fan-out (P5 §4.3, boris-accepted form (б) + source (1)).
4
+ *
5
+ * The weekly DREAM_TICK lands in a FRESH index session; the Index shells
6
+ * THIS verb and fans DreamWeaver out over its output — one agent-memory
7
+ * subfolder per task, transcript globs riding along. Resolution happens AT
8
+ * THE TICK, never baked into the timer registration: a baked snapshot
9
+ * re-creates the «фаза D мертва, glob-skip маскирует» class one layer down.
10
+ *
11
+ * SOURCE = the LIVE registry (`iapeer list --json`), not fleet.json — the
12
+ * freshness proof (facts, 11.06): birth-provision does NOT touch fleet.json
13
+ * (writeFleetMap call sites: init/update/verify --repair only) and the
14
+ * SessionStart kick is heartbeat-gated (silent on a healthy host), so a
15
+ * peer born after the last update is INVISIBLE to fleet.json for weeks —
16
+ * the live registry is the only source that sees it. Read-as-egress: a
17
+ * legitimate live channel of the prod CLI (the refusing test handle blocks
18
+ * it; hermetic tests pass --iapeer-bin).
19
+ *
20
+ * READ-ONLY by contract: one registry list spawn + vault readdir +
21
+ * realpath. No writes, no signals, no detached spawns.
22
+ *
23
+ * Transcript path forms (host facts):
24
+ * claude — `~/.claude/projects/<slug(cwd)>/*.jsonl`; slug = every
25
+ * non-alphanumeric of the REGISTRY cwd → '-' (live form:
26
+ * /Users/macmini/.iapeer/peers/index → -Users-macmini--iapeer-peers-index;
27
+ * the registry cwd verbatim, NOT realpath — claude slugs the path the
28
+ * session launched in);
29
+ * codex — `~/.codex/sessions/**\/rollout-*.jsonl` (HOST-WIDE pool) +
30
+ * `cwdFilter` = realpath(cwd): the worker matches the payload's
31
+ * session_meta.cwd — the iapeer-contract realpath rule.
32
+ *
33
+ * Folders without a live peer get `transcripts: []` — phase D skips them
34
+ * honestly (A–C still run); peers without a memory subfolder are not in
35
+ * the fan-out (nothing to consolidate).
36
+ */
37
+
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ import { getTaxonomy, isLocaleId } from "@agfpd/iapeer-memory-core";
42
+ import type { Egress } from "../egress.js";
43
+ import { queryRegistry, type FleetPeer } from "../fleet.js";
44
+
45
+ /** Claude projects-dir slug — the live disk form (ls ~/.claude/projects). */
46
+ export function claudeProjectSlug(cwd: string): string {
47
+ return cwd.replace(/[^A-Za-z0-9]/g, "-");
48
+ }
49
+
50
+ export type TranscriptSpec = {
51
+ runtime: "claude" | "codex";
52
+ glob: string;
53
+ /** codex only: the worker filters the HOST-WIDE pool by the payload's
54
+ * session_meta.cwd against this realpath (iapeer contract). */
55
+ cwdFilter?: string;
56
+ };
57
+
58
+ export function transcriptSpecs(peer: FleetPeer, home: string): TranscriptSpec[] {
59
+ const specs: TranscriptSpec[] = [];
60
+ if (peer.runtimes.includes("claude")) {
61
+ specs.push({
62
+ runtime: "claude",
63
+ glob: path.join(home, ".claude", "projects", claudeProjectSlug(peer.cwd), "*.jsonl"),
64
+ });
65
+ }
66
+ if (peer.runtimes.includes("codex")) {
67
+ let real = peer.cwd;
68
+ try {
69
+ real = fs.realpathSync(peer.cwd);
70
+ } catch {
71
+ // vanished cwd — keep the registry form; the filter simply matches nothing
72
+ }
73
+ specs.push({
74
+ runtime: "codex",
75
+ glob: path.join(home, ".codex", "sessions", "**", "rollout-*.jsonl"),
76
+ cwdFilter: real,
77
+ });
78
+ }
79
+ return specs;
80
+ }
81
+
82
+ export type DreamFolder = {
83
+ agent: string;
84
+ path: string;
85
+ transcripts: TranscriptSpec[];
86
+ };
87
+
88
+ export function buildDreamPaths(opts: {
89
+ vault: string;
90
+ agentMemoryFolder: string;
91
+ peers: FleetPeer[];
92
+ home: string;
93
+ }): DreamFolder[] {
94
+ const memoryRoot = path.join(opts.vault, opts.agentMemoryFolder);
95
+ let entries: fs.Dirent[];
96
+ try {
97
+ entries = fs.readdirSync(memoryRoot, { withFileTypes: true });
98
+ } catch {
99
+ return [];
100
+ }
101
+ const byPersonality = new Map(opts.peers.map((p) => [p.personality, p]));
102
+ return entries
103
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
104
+ .sort((a, b) => a.name.localeCompare(b.name))
105
+ .map((e) => {
106
+ const peer = byPersonality.get(e.name);
107
+ return {
108
+ agent: e.name,
109
+ path: path.join(memoryRoot, e.name),
110
+ transcripts: peer ? transcriptSpecs(peer, opts.home) : [],
111
+ };
112
+ });
113
+ }
114
+
115
+ export function cmdDreamPaths(argv: string[], egress: Egress): number {
116
+ let iapeerBin: string | undefined;
117
+ for (let i = 0; i < argv.length; i++) {
118
+ const a = argv[i];
119
+ if (a === "--iapeer-bin") iapeerBin = argv[++i];
120
+ else {
121
+ console.error(`iapeer-memory dream-paths: unknown flag: ${a}`);
122
+ return 2;
123
+ }
124
+ }
125
+
126
+ const vault = process.env.IAPEER_MEMORY_VAULT_PATH ?? "";
127
+ if (!vault) {
128
+ console.error("iapeer-memory dream-paths: IAPEER_MEMORY_VAULT_PATH is not set — not provisioned");
129
+ return 1;
130
+ }
131
+ const localeRaw = process.env.IAPEER_MEMORY_LOCALE || "en";
132
+ if (!isLocaleId(localeRaw)) {
133
+ console.error(`iapeer-memory dream-paths: unknown locale "${localeRaw}"`);
134
+ return 1;
135
+ }
136
+
137
+ const q = queryRegistry(egress, { iapeerBin });
138
+ if ("error" in q) {
139
+ // LOUD: a silent empty fan-out would re-create the masked-dead-phase
140
+ // class — the Index sees this line and reports instead of guessing.
141
+ console.error(`iapeer-memory dream-paths: live registry unavailable — ${q.error}`);
142
+ return 1;
143
+ }
144
+
145
+ const folders = buildDreamPaths({
146
+ vault,
147
+ agentMemoryFolder: getTaxonomy(localeRaw).folders.agentMemory,
148
+ peers: q.peers,
149
+ home: os.homedir(),
150
+ });
151
+ console.log(JSON.stringify({ vault, folders }, null, 2));
152
+ return 0;
153
+ }
@@ -44,13 +44,64 @@ import { memoryPaths, type MemoryPaths } from "../paths.js";
44
44
  import { DEFAULT_HEARTBEAT_STALE_MS } from "./verify.js";
45
45
  import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
46
46
 
47
- /** Tools whose writes stamp frontmatter. P5 adds "apply_patch" (codex). */
47
+ /** Tools whose writes stamp frontmatter: claude Write|Edit|MultiEdit +
48
+ * codex apply_patch (Ш2; stdin-JSON is Claude-compatible — canon
49
+ * «Поверхности конфигурации codex» §Хуки). */
48
50
  export const POST_WRITE_TOOLS: ReadonlySet<string> = new Set([
49
51
  "Write",
50
52
  "Edit",
51
53
  "MultiEdit",
54
+ "apply_patch",
52
55
  ]);
53
56
 
57
+ /** apply_patch envelope markers (the public codex patch format) — the
58
+ * deterministic path source from tool_input's patch text. */
59
+ const PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
60
+
61
+ /** `tool_response` status lines — the VERBATIM form established by the live
62
+ * Ш2 e2e (codex-cli 0.138.0, gpt-5.5, captured stdin 11.06):
63
+ * a single STRING `"Exit code: 0\nWall time: …\nOutput:\nSuccess. Updated
64
+ * the following files:\nA /abs/path.md\n"` — `A`/`M`/`D` markers per file. */
65
+ const RESPONSE_FILE_RE = /^[AMD]\s+(.+)$/;
66
+
67
+ /**
68
+ * File-path candidates of a codex apply_patch event. Two sources, union:
69
+ * the envelope markers inside the patch text (scans every string value of
70
+ * tool_input — the live field is `command`, the name is not load-bearing)
71
+ * and the `A/M/D <path>` lines of the tool_response string (verbatim form
72
+ * above; a D path simply fails the existsSync gate downstream). Relative
73
+ * paths resolve against the event's `cwd` (stdin carries it — live fact).
74
+ */
75
+ export function applyPatchPaths(event: {
76
+ cwd?: string;
77
+ tool_input?: unknown;
78
+ tool_response?: unknown;
79
+ }): string[] {
80
+ const found = new Set<string>();
81
+ const add = (raw: string): void => {
82
+ const p = raw.trim();
83
+ if (!p) return;
84
+ found.add(path.isAbsolute(p) ? p : path.resolve(event.cwd ?? process.cwd(), p));
85
+ };
86
+ const input = event.tool_input;
87
+ if (input && typeof input === "object") {
88
+ for (const v of Object.values(input as Record<string, unknown>)) {
89
+ if (typeof v !== "string") continue;
90
+ for (const m of v.matchAll(PATCH_FILE_RE)) add(m[1]);
91
+ }
92
+ } else if (typeof input === "string") {
93
+ for (const m of input.matchAll(PATCH_FILE_RE)) add(m[1]);
94
+ }
95
+ const resp = event.tool_response;
96
+ if (typeof resp === "string") {
97
+ for (const line of resp.split("\n")) {
98
+ const m = RESPONSE_FILE_RE.exec(line);
99
+ if (m) add(m[1]);
100
+ }
101
+ }
102
+ return [...found];
103
+ }
104
+
54
105
  /** Min interval between background verify-kicks (anti-storm). */
55
106
  export const KICK_DEBOUNCE_MS = 5 * 60_000;
56
107
 
@@ -77,7 +128,12 @@ export function runPostWrite(
77
128
  ): PostWriteResult {
78
129
  const silent: PostWriteResult = { stamped: false, output: null };
79
130
 
80
- let event: { tool_name?: string; tool_input?: { file_path?: string } };
131
+ let event: {
132
+ tool_name?: string;
133
+ cwd?: string;
134
+ tool_input?: { file_path?: string };
135
+ tool_response?: unknown;
136
+ };
81
137
  try {
82
138
  event = JSON.parse(eventJson) as typeof event;
83
139
  } catch {
@@ -88,15 +144,20 @@ export function runPostWrite(
88
144
  // reference live-smoke fact; the same ordering keeps claude cheap too).
89
145
  if (!POST_WRITE_TOOLS.has(tool)) return silent;
90
146
 
91
- const filePath = event.tool_input?.file_path ?? "";
92
- if (!filePath.endsWith(".md")) return silent;
147
+ // Path candidates: claude tools carry ONE file_path; codex apply_patch
148
+ // carries a patch over possibly MANY files (Ш2).
149
+ const candidates =
150
+ tool === "apply_patch"
151
+ ? applyPatchPaths(event)
152
+ : [event.tool_input?.file_path ?? ""];
93
153
 
94
154
  const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
95
155
  if (!vault) return silent; // socket without a provisioned system
96
- if (!filePath.startsWith(vault.endsWith(path.sep) ? vault : vault + path.sep)) {
97
- return silent; // outside the vault — никогда не трогаем чужие файлы
98
- }
99
- if (!fs.existsSync(filePath)) return silent; // failed write — nothing to stamp
156
+ const vaultPrefix = vault.endsWith(path.sep) ? vault : vault + path.sep;
157
+ const files = candidates.filter(
158
+ (p) => p.endsWith(".md") && p.startsWith(vaultPrefix) && fs.existsSync(p),
159
+ );
160
+ if (files.length === 0) return silent;
100
161
 
101
162
  // Identity: PEER_PERSONALITY → IAPEER_MEMORY_AGENT_NAME. NO cwd guessing
102
163
  // (нюанс 10 — deliberate divergence from the reference basename(PWD)).
@@ -112,7 +173,7 @@ export function runPostWrite(
112
173
  .filter(Boolean);
113
174
 
114
175
  fmUpdate({
115
- files: [filePath],
176
+ files,
116
177
  ops: [],
117
178
  agent,
118
179
  vault,
@@ -121,12 +182,15 @@ export function runPostWrite(
121
182
  stamp: true,
122
183
  });
123
184
 
124
- // Reminder: ONLY on Write (new note) in the author's OWN memory folder —
125
- // an Edit-loop on one note must not spam the context (reference semantics).
185
+ // Reminder: ONLY on a claude Write (new note) in the author's OWN memory
186
+ // folder — an Edit-loop must not spam the context (reference semantics).
187
+ // The codex branch NEVER emits: hookSpecificOutput.additionalContext is a
188
+ // claude protocol — codex support is unverified (upstream issue #19385
189
+ // ASKS for it, which reads as «not there»; fact-checked stance, not a gap).
126
190
  const ownMemoryDir =
127
191
  path.join(vault, taxonomy.folders.agentMemory, agent) + path.sep;
128
192
  const output =
129
- tool === "Write" && filePath.startsWith(ownMemoryDir)
193
+ tool === "Write" && files[0]!.startsWith(ownMemoryDir)
130
194
  ? JSON.stringify({
131
195
  hookSpecificOutput: {
132
196
  hookEventName: "PostToolUse",
@@ -444,7 +444,7 @@ export async function cmdInit(argv: string[], egress: Egress): Promise<number> {
444
444
  const fleet = readFleetMap(paths.fleetMapPath) ?? [];
445
445
  const locked = withProvisionLock({
446
446
  stateDir: paths.stateDir,
447
- fn: () => sweepProvision({ fleet, hooksDir: paths.hooksDir, port: mcpPort() }),
447
+ fn: () => sweepProvision(egress, { fleet, hooksDir: paths.hooksDir, port: mcpPort(), iapeerBin: flags.iapeerBin }),
448
448
  });
449
449
  if (!locked.acquired) {
450
450
  step("surfaces", locked.detail, false);
@@ -19,12 +19,13 @@
19
19
  * branched on.
20
20
  *
21
21
  * Runtime forms: claude — hooks + mcp + skills (surfaces/claude.ts);
22
- * codex — per-peer MCP via `<cwd>/.codex/config.toml` (surfaces/codex.ts;
23
- * hooks/skills are the P5 experiment). Exit: 0 ok, 1 a surface failed,
24
- * 2 usage.
22
+ * codex — per-peer MCP via `<cwd>/.codex/config.toml` + hooks.json with the
23
+ * core's trust-hooks pre-seed (surfaces/codex.ts, Ш2; skills deliberately
24
+ * not delivered — P5 §4.2). Exit: 0 ok, 1 a surface failed, 2 usage.
25
25
  */
26
26
 
27
27
  import fs from "node:fs";
28
+ import type { Egress } from "../egress.js";
28
29
  import { memoryPaths } from "../paths.js";
29
30
  import {
30
31
  provisionClaudePeer,
@@ -53,6 +54,8 @@ type Flags = {
53
54
  runtime: (typeof RUNTIMES)[number];
54
55
  occasion: Occasion;
55
56
  personality: string;
57
+ /** Explicitly named core binary (hermetic tests; egress explicit-bin). */
58
+ iapeerBin?: string;
56
59
  };
57
60
 
58
61
  function parseFlags(
@@ -64,6 +67,7 @@ function parseFlags(
64
67
  let runtime = "";
65
68
  let occasion: string = defaultOccasion;
66
69
  let personality = "";
70
+ let iapeerBin: string | undefined;
67
71
  for (let i = 0; i < argv.length; i++) {
68
72
  const a = argv[i];
69
73
  switch (a) {
@@ -71,6 +75,7 @@ function parseFlags(
71
75
  case "--runtime": runtime = argv[++i] ?? ""; break;
72
76
  case "--occasion": occasion = argv[++i] ?? ""; break;
73
77
  case "--personality": personality = argv[++i] ?? ""; break;
78
+ case "--iapeer-bin": iapeerBin = argv[++i]; break;
74
79
  default:
75
80
  console.error(`iapeer-memory ${verb}: unknown flag: ${a}`);
76
81
  return null;
@@ -105,6 +110,7 @@ function parseFlags(
105
110
  runtime: runtime as Flags["runtime"],
106
111
  occasion: occasion as Occasion,
107
112
  personality,
113
+ iapeerBin,
108
114
  };
109
115
  }
110
116
 
@@ -124,7 +130,7 @@ function report(verb: string, flags: Flags, outcomes: SurfaceOutcome[]): number
124
130
  return failed ? 1 : 0;
125
131
  }
126
132
 
127
- export function cmdProvisionPeer(argv: string[]): number {
133
+ export function cmdProvisionPeer(argv: string[], egress: Egress): number {
128
134
  const flags = parseFlags("provision-peer", argv, "sweep-on");
129
135
  if (!flags) return 2;
130
136
  if (!fs.existsSync(flags.cwd)) {
@@ -136,7 +142,12 @@ export function cmdProvisionPeer(argv: string[]): number {
136
142
  stateDir: paths.stateDir,
137
143
  fn: () =>
138
144
  flags.runtime === "codex"
139
- ? provisionCodexPeer({ cwd: flags.cwd, port: mcpPort() })
145
+ ? provisionCodexPeer(egress, {
146
+ cwd: flags.cwd,
147
+ port: mcpPort(),
148
+ hooksDir: paths.hooksDir,
149
+ iapeerBin: flags.iapeerBin,
150
+ })
140
151
  : provisionClaudePeer({
141
152
  cwd: flags.cwd,
142
153
  hooksDir: paths.hooksDir,
@@ -161,7 +161,7 @@ export function cmdUpdate(argv: string[], egress: Egress): number {
161
161
  const fleet = readFleetMap(paths.fleetMapPath) ?? [];
162
162
  const locked = withProvisionLock({
163
163
  stateDir: paths.stateDir,
164
- fn: () => sweepProvision({ fleet, hooksDir: paths.hooksDir, port: mcpPort() }),
164
+ fn: () => sweepProvision(egress, { fleet, hooksDir: paths.hooksDir, port: mcpPort(), iapeerBin }),
165
165
  });
166
166
  if (!locked.acquired) {
167
167
  step("surfaces", locked.detail, false);
@@ -221,7 +221,7 @@ export function runVerify(egress: Egress, opts: VerifyOptions = {}): CheckResult
221
221
  detail: "fleet map unreadable — see fleet-map check",
222
222
  });
223
223
  } else {
224
- const { checks, skipped } = checkFleetSurfaces({
224
+ const { checks, skipped } = checkFleetSurfaces(egress, {
225
225
  fleet,
226
226
  hooksDir: paths.hooksDir,
227
227
  port: mcpPort(),
@@ -247,7 +247,7 @@ export function runVerify(egress: Egress, opts: VerifyOptions = {}): CheckResult
247
247
  const badPeers = fleet.filter((p) => bad.some((b) => b.cwd === p.cwd));
248
248
  const locked = withProvisionLock({
249
249
  stateDir: paths.stateDir,
250
- fn: () => sweepProvision({ fleet: badPeers, hooksDir: paths.hooksDir, port: mcpPort() }),
250
+ fn: () => sweepProvision(egress, { fleet: badPeers, hooksDir: paths.hooksDir, port: mcpPort(), iapeerBin: opts.iapeerBin }),
251
251
  });
252
252
  if (!locked.acquired) {
253
253
  results.push({ name: "peer-surfaces", status: "fail", detail: locked.detail });
package/src/fleet.ts CHANGED
@@ -70,65 +70,72 @@ export function readFleetMap(fleetMapPath: string): FleetPeer[] | null {
70
70
  }
71
71
  }
72
72
 
73
- export function writeFleetMap(
73
+ /** Live-registry query — the ONE place `iapeer list --json` is parsed.
74
+ * Shared by writeFleetMap (the persisted map) and dream-paths (the
75
+ * tick-time resolution; freshness fact: birth does NOT touch fleet.json
76
+ * and the SessionStart kick is heartbeat-gated, so the LIVE registry is
77
+ * the only source that sees a newborn before the next update). */
78
+ export function queryRegistry(
74
79
  egress: Egress,
75
- opts: {
76
- fleetMapPath: string;
77
- iapeerBin?: string;
78
- /** Injectable for tests. */
79
- nowIso?: string;
80
- },
81
- ): FleetMapResult {
80
+ opts: { iapeerBin?: string },
81
+ ): { peers: FleetPeer[] } | { error: string } {
82
82
  const bin = opts.iapeerBin ?? IAPEER_BIN;
83
83
  const proc = egress.spawnSync([bin, "list", "--json"], {
84
84
  explicitBin: opts.iapeerBin !== undefined,
85
85
  });
86
86
  if (proc.refused) {
87
- return {
88
- action: "failed",
89
- count: 0,
90
- detail: "live-registry query suppressed (test sandbox) — pass a fake iapeerBin",
91
- };
87
+ return { error: "live-registry query suppressed (test sandbox) — pass a fake iapeerBin" };
92
88
  }
93
89
  if (proc.spawnError) {
94
- return { action: "failed", count: 0, detail: `${bin} unavailable: ${proc.spawnError}` };
90
+ return { error: `${bin} unavailable: ${proc.spawnError}` };
95
91
  }
96
92
  if (proc.exitCode !== 0) {
97
- return {
98
- action: "failed",
99
- count: 0,
100
- detail: (proc.stderr.trim() || `iapeer list exited ${proc.exitCode}`).slice(0, 160),
101
- };
93
+ return { error: (proc.stderr.trim() || `iapeer list exited ${proc.exitCode}`).slice(0, 160) };
102
94
  }
103
- const stdout = proc.stdout;
104
-
105
95
  let listed: ListedPeer[];
106
96
  try {
107
- const raw = JSON.parse(stdout) as unknown;
97
+ const raw = JSON.parse(proc.stdout) as unknown;
108
98
  listed = Array.isArray(raw) ? (raw as ListedPeer[]) : [];
109
99
  } catch {
110
- return { action: "failed", count: 0, detail: "iapeer list --json: unparsable output" };
100
+ return { error: "iapeer list --json: unparsable output" };
111
101
  }
102
+ return {
103
+ peers: listed
104
+ .filter(
105
+ (p): p is ListedPeer & { personality: string; cwd: string } =>
106
+ typeof p.personality === "string" &&
107
+ p.personality.trim() !== "" &&
108
+ typeof p.cwd === "string" &&
109
+ p.cwd.trim() !== "",
110
+ )
111
+ .map((p) => ({
112
+ personality: p.personality.trim(),
113
+ cwd: p.cwd.trim(),
114
+ runtimes: [
115
+ ...new Set(
116
+ (Array.isArray(p.runtimes) ? p.runtimes : [])
117
+ .map((r) => (typeof r?.runtime === "string" ? r.runtime.trim() : ""))
118
+ .filter(Boolean),
119
+ ),
120
+ ],
121
+ })),
122
+ };
123
+ }
112
124
 
113
- const peers: FleetPeer[] = listed
114
- .filter(
115
- (p): p is ListedPeer & { personality: string; cwd: string } =>
116
- typeof p.personality === "string" &&
117
- p.personality.trim() !== "" &&
118
- typeof p.cwd === "string" &&
119
- p.cwd.trim() !== "",
120
- )
121
- .map((p) => ({
122
- personality: p.personality.trim(),
123
- cwd: p.cwd.trim(),
124
- runtimes: [
125
- ...new Set(
126
- (Array.isArray(p.runtimes) ? p.runtimes : [])
127
- .map((r) => (typeof r?.runtime === "string" ? r.runtime.trim() : ""))
128
- .filter(Boolean),
129
- ),
130
- ],
131
- }));
125
+ export function writeFleetMap(
126
+ egress: Egress,
127
+ opts: {
128
+ fleetMapPath: string;
129
+ iapeerBin?: string;
130
+ /** Injectable for tests. */
131
+ nowIso?: string;
132
+ },
133
+ ): FleetMapResult {
134
+ const q = queryRegistry(egress, { iapeerBin: opts.iapeerBin });
135
+ if ("error" in q) {
136
+ return { action: "failed", count: 0, detail: q.error };
137
+ }
138
+ const peers = q.peers;
132
139
 
133
140
  const body =
134
141
  JSON.stringify(
@@ -62,9 +62,16 @@ export function isOurHookCommand(command: string): boolean {
62
62
  return base.startsWith(HOOK_SHIM_PREFIX) && base.endsWith(".sh");
63
63
  }
64
64
 
65
- export type SurfaceAction = "written" | "already" | "removed" | "absent" | "failed";
65
+ export type SurfaceAction =
66
+ | "written"
67
+ | "already"
68
+ | "removed"
69
+ | "absent"
70
+ | "failed"
71
+ /** A live-host step suppressed by the refusing egress (test sandbox). */
72
+ | "skipped";
66
73
  export type SurfaceOutcome = {
67
- surface: "hooks" | "mcp" | "skills";
74
+ surface: "hooks" | "mcp" | "skills" | "trust";
68
75
  action: SurfaceAction;
69
76
  path: string;
70
77
  detail?: string;
@@ -140,7 +147,7 @@ export function expectedMcpEntry(opts: {
140
147
  };
141
148
  }
142
149
 
143
- type HookEntry = {
150
+ export type HookEntry = {
144
151
  matcher?: string;
145
152
  hooks: Array<{ type: string; command: string }>;
146
153
  };
@@ -164,7 +171,7 @@ export function expectedHookEntries(
164
171
  type JsonObject = Record<string, unknown>;
165
172
 
166
173
  /** null = unreadable-as-object (refuse to clobber); {} when absent. */
167
- function readJsonObject(filePath: string): JsonObject | null | "absent" {
174
+ export function readJsonObject(filePath: string): JsonObject | null | "absent" {
168
175
  let raw: string;
169
176
  try {
170
177
  raw = fs.readFileSync(filePath, "utf-8");
@@ -180,11 +187,11 @@ function readJsonObject(filePath: string): JsonObject | null | "absent" {
180
187
  }
181
188
  }
182
189
 
183
- function sameJson(a: unknown, b: unknown): boolean {
190
+ export function sameJson(a: unknown, b: unknown): boolean {
184
191
  return JSON.stringify(a) === JSON.stringify(b);
185
192
  }
186
193
 
187
- function isOurHookEntry(entry: unknown): boolean {
194
+ export function isOurHookEntry(entry: unknown): boolean {
188
195
  if (!entry || typeof entry !== "object") return false;
189
196
  const hooks = (entry as HookEntry).hooks;
190
197
  if (!Array.isArray(hooks)) return false;
@@ -29,13 +29,22 @@
29
29
  * Merge = line-based section surgery on OUR header namespace only
30
30
  * (`[mcp_servers.iapeer-memory]` + its subsections), atomic write; unlike
31
31
  * the core's append-if-absent we REPLACE a drifted block (repair duty,
32
- * требование №2). Hooks/skills for codex are the P5 experiment — MCP is
33
- * deliberately the only codex surface here.
32
+ * требование №2). Hooks (Ш2) live in `<cwd>/.codex/hooks.json` below;
33
+ * skills for codex are deliberately NOT delivered (P5 §4.2, boris-agreed).
34
34
  */
35
35
 
36
36
  import fs from "node:fs";
37
37
  import path from "node:path";
38
- import type { SurfaceOutcome } from "./claude.js";
38
+ import { IAPEER_BIN, type Egress } from "../egress.js";
39
+ import {
40
+ isOurHookEntry,
41
+ materialiseShims,
42
+ readJsonObject,
43
+ sameJson,
44
+ shimPath,
45
+ type HookEntry,
46
+ type SurfaceOutcome,
47
+ } from "./claude.js";
39
48
  import { guardedWriteFileSync, guardedUnlinkSync } from "@agfpd/iapeer-memory-core";
40
49
 
41
50
  export const CODEX_MCP_SECTION = "mcp_servers.iapeer-memory";
@@ -123,34 +132,323 @@ export function removeCodexMcp(opts: { cwd: string }): SurfaceOutcome {
123
132
  return { surface: "mcp", action: "removed", path: configPath };
124
133
  }
125
134
 
126
- export function provisionCodexPeer(opts: { cwd: string; port: number }): SurfaceOutcome[] {
127
- return [mergeCodexMcp(opts)];
135
+ // ── hooks surface (Ш2, P5_CODEX_ADAPTER_DESIGN §4.1) ────────────────────────
136
+ //
137
+ // File-based (non-plugin) hooks — live-proven by the iapeer smoke 11.06
138
+ // (codex-cli 0.138.0): `<cwd>/.codex/hooks.json` of a TRUSTED cwd, format
139
+ // Claude-compatible. The SAME shims serve both runtimes (all logic lives in
140
+ // the CLI verbs; codex stdin-JSON is Claude-compatible — canon note
141
+ // «Поверхности конфигурации codex» §Хуки).
142
+ //
143
+ // NO matcher ON PURPOSE: matcher inclusion in the upstream trust-hash
144
+ // identity is unverified (canon note) and an untrusted hook in headless
145
+ // exec SKIPS SILENTLY — the worst failure mode. Without a matcher the
146
+ // identity walks the verified algorithm branch; the CLI's cheap tool_name
147
+ // gate (hook.ts) filters the per-tool noise instead.
148
+ //
149
+ // Trust pre-seed: `iapeer trust-hooks <realpath>` (core ≥0.2.32) — the verb
150
+ // owns the hash algorithm; we never compute it (agreed: one point of
151
+ // truth). Cleanup of [hooks.state] on peer REMOVAL is the core's rail;
152
+ // off-peer/off-all leave orphan records — harmless (keyed on the realpath
153
+ // of a file we just removed; codex finds nothing to run).
154
+
155
+ export function codexHooksJsonPath(cwd: string): string {
156
+ return path.join(cwd, ".codex", "hooks.json");
157
+ }
158
+
159
+ export function expectedCodexHookEntries(
160
+ hooksDir: string,
161
+ ): Record<"PostToolUse" | "SessionStart", HookEntry> {
162
+ return {
163
+ PostToolUse: {
164
+ hooks: [{ type: "command", command: shimPath(hooksDir, "post-write") }],
165
+ },
166
+ SessionStart: {
167
+ hooks: [{ type: "command", command: shimPath(hooksDir, "session-start") }],
168
+ },
169
+ };
170
+ }
171
+
172
+ export function mergeCodexHooks(opts: { cwd: string; hooksDir: string }): SurfaceOutcome {
173
+ const hooksJson = codexHooksJsonPath(opts.cwd);
174
+ const current = readJsonObject(hooksJson);
175
+ if (current === null) {
176
+ return {
177
+ surface: "hooks",
178
+ action: "failed",
179
+ path: hooksJson,
180
+ detail: "hooks.json is not a JSON object — refusing to clobber",
181
+ };
182
+ }
183
+ const obj: Record<string, unknown> = current === "absent" ? {} : current;
184
+ const hooksRaw = obj.hooks;
185
+ if (
186
+ hooksRaw !== undefined &&
187
+ (typeof hooksRaw !== "object" || Array.isArray(hooksRaw) || hooksRaw === null)
188
+ ) {
189
+ return {
190
+ surface: "hooks",
191
+ action: "failed",
192
+ path: hooksJson,
193
+ detail: "hooks.json `hooks` is not an object — refusing to clobber",
194
+ };
195
+ }
196
+ const hooks: Record<string, unknown> = (hooksRaw as Record<string, unknown> | undefined) ?? {};
197
+ const expected = expectedCodexHookEntries(opts.hooksDir);
198
+ let changed = false;
199
+ for (const event of ["PostToolUse", "SessionStart"] as const) {
200
+ const listRaw = hooks[event];
201
+ const list: unknown[] = Array.isArray(listRaw) ? listRaw : [];
202
+ const ours = list.filter(isOurHookEntry);
203
+ if (ours.length === 1 && sameJson(ours[0], expected[event])) continue;
204
+ const foreign = list.filter((e) => !isOurHookEntry(e));
205
+ hooks[event] = [...foreign, expected[event]];
206
+ changed = true;
207
+ }
208
+ if (!changed) {
209
+ return { surface: "hooks", action: "already", path: hooksJson };
210
+ }
211
+ obj.hooks = hooks;
212
+ writeFileAtomic(hooksJson, `${JSON.stringify(obj, null, 2)}\n`);
213
+ return { surface: "hooks", action: "written", path: hooksJson };
214
+ }
215
+
216
+ export function removeCodexHooks(opts: { cwd: string }): SurfaceOutcome {
217
+ const hooksJson = codexHooksJsonPath(opts.cwd);
218
+ const current = readJsonObject(hooksJson);
219
+ if (current === "absent") {
220
+ return { surface: "hooks", action: "absent", path: hooksJson };
221
+ }
222
+ if (current === null) {
223
+ return {
224
+ surface: "hooks",
225
+ action: "failed",
226
+ path: hooksJson,
227
+ detail: "hooks.json is not a JSON object — refusing to touch",
228
+ };
229
+ }
230
+ const hooksRaw = current.hooks;
231
+ if (!hooksRaw || typeof hooksRaw !== "object" || Array.isArray(hooksRaw)) {
232
+ return { surface: "hooks", action: "absent", path: hooksJson };
233
+ }
234
+ const hooks = hooksRaw as Record<string, unknown>;
235
+ let changed = false;
236
+ for (const event of Object.keys(hooks)) {
237
+ const list = hooks[event];
238
+ if (!Array.isArray(list)) continue;
239
+ const kept = list
240
+ .map((entry) => {
241
+ if (!isOurHookEntry(entry)) return entry;
242
+ const e = entry as HookEntry;
243
+ const foreignHooks = e.hooks.filter(
244
+ (h) => !(typeof h?.command === "string" && h.command.split("/").pop()?.startsWith("iapeer-memory.")),
245
+ );
246
+ if (foreignHooks.length === 0) return null;
247
+ return { ...e, hooks: foreignHooks };
248
+ })
249
+ .filter((e): e is NonNullable<typeof e> => e !== null);
250
+ if (kept.length !== list.length || !sameJson(kept, list)) changed = true;
251
+ if (kept.length === 0) delete hooks[event];
252
+ else hooks[event] = kept;
253
+ }
254
+ if (!changed) {
255
+ return { surface: "hooks", action: "absent", path: hooksJson };
256
+ }
257
+ if (Object.keys(hooks).length === 0) delete current.hooks;
258
+ if (Object.keys(current).length === 0) {
259
+ // nothing but our hooks lived here — leave the cwd exactly as found
260
+ guardedUnlinkSync(hooksJson);
261
+ return { surface: "hooks", action: "removed", path: hooksJson, detail: "file removed (empty after our entries)" };
262
+ }
263
+ writeFileAtomic(hooksJson, `${JSON.stringify(current, null, 2)}\n`);
264
+ return { surface: "hooks", action: "removed", path: hooksJson };
265
+ }
266
+
267
+ /** Trust pre-seed via the core verb (≥0.2.32). The refusing egress maps to
268
+ * a SKIP (sandbox never touches the live host config); a failed verb is a
269
+ * LOUD failure — an untrusted hook skips silently in headless, the worst
270
+ * degradation mode (boris's acceptance condition: visible only). */
271
+ export function trustCodexHooks(
272
+ egress: Egress,
273
+ opts: { hooksJsonPath: string; iapeerBin?: string },
274
+ ): SurfaceOutcome {
275
+ let real: string;
276
+ try {
277
+ real = fs.realpathSync(opts.hooksJsonPath); // trust keys on the REALPATH (core contract)
278
+ } catch {
279
+ return {
280
+ surface: "trust",
281
+ action: "failed",
282
+ path: opts.hooksJsonPath,
283
+ detail: "hooks.json unreadable for realpath — trust not attempted",
284
+ };
285
+ }
286
+ const bin = opts.iapeerBin ?? IAPEER_BIN;
287
+ const proc = egress.spawnSync([bin, "trust-hooks", real], {
288
+ explicitBin: opts.iapeerBin !== undefined,
289
+ });
290
+ if (proc.refused) {
291
+ return {
292
+ surface: "trust",
293
+ action: "skipped",
294
+ path: real,
295
+ detail: "suppressed (test sandbox) — live pre-seed runs on the host",
296
+ };
297
+ }
298
+ if (proc.spawnError) {
299
+ return { surface: "trust", action: "failed", path: real, detail: `${bin} unavailable: ${proc.spawnError}` };
300
+ }
301
+ if (proc.exitCode !== 0) {
302
+ return {
303
+ surface: "trust",
304
+ action: "failed",
305
+ path: real,
306
+ detail:
307
+ (proc.stderr.trim() || proc.stdout.trim() || `trust-hooks exited ${proc.exitCode}`).slice(0, 200) +
308
+ " — hooks stay UNTRUSTED (headless codex skips them silently); core ≥0.2.32 required",
309
+ };
310
+ }
311
+ const line = proc.stdout.trim().split("\n")[0] ?? "";
312
+ return {
313
+ surface: "trust",
314
+ action: line.toLowerCase().includes("already") ? "already" : "written",
315
+ path: real,
316
+ detail: line || undefined,
317
+ };
318
+ }
319
+
320
+ export function provisionCodexPeer(
321
+ egress: Egress,
322
+ opts: { cwd: string; port: number; hooksDir: string; iapeerBin?: string },
323
+ ): SurfaceOutcome[] {
324
+ // Shims first — the merged hooks.json must never point at a void (same
325
+ // ordering duty as the claude provision).
326
+ materialiseShims(opts.hooksDir);
327
+ const mcp = mergeCodexMcp({ cwd: opts.cwd, port: opts.port });
328
+ const hooks = mergeCodexHooks({ cwd: opts.cwd, hooksDir: opts.hooksDir });
329
+ const trust =
330
+ hooks.action === "failed"
331
+ ? ({
332
+ surface: "trust",
333
+ action: "failed",
334
+ path: codexHooksJsonPath(opts.cwd),
335
+ detail: "hooks surface failed — trust not attempted",
336
+ } as SurfaceOutcome)
337
+ : trustCodexHooks(egress, {
338
+ hooksJsonPath: codexHooksJsonPath(opts.cwd),
339
+ iapeerBin: opts.iapeerBin,
340
+ });
341
+ return [mcp, hooks, trust];
128
342
  }
129
343
 
130
344
  export function unprovisionCodexPeer(opts: { cwd: string }): SurfaceOutcome[] {
131
- return [removeCodexMcp(opts)];
345
+ // [hooks.state] cleanup is the core's rail (`iapeer remove`); off-peer/
346
+ // off-all leave orphan records keyed on a now-absent file — harmless.
347
+ return [removeCodexMcp(opts), removeCodexHooks(opts)];
132
348
  }
133
349
 
134
- /** Read-only drift check (verify's eye, D3): the expected block must sit in
135
- * the project-local config byte-exact. */
136
- export function checkCodexPeer(opts: {
137
- cwd: string;
138
- port: number;
139
- }): Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> {
350
+ /** Read-only drift check (verify's eye, D3 + Ш2): the MCP block byte-exact,
351
+ * our hook entries in hooks.json, and the trust state via the core's
352
+ * `trust-hooks --check` (the hash algorithm lives in ONE place — the core;
353
+ * we render its verdict, never compute it). Degradation is VISIBLE by
354
+ * acceptance condition: an untrusted hook skips silently in headless. */
355
+ export function checkCodexPeer(
356
+ egress: Egress,
357
+ opts: {
358
+ cwd: string;
359
+ port: number;
360
+ hooksDir: string;
361
+ iapeerBin?: string;
362
+ },
363
+ ): Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> {
364
+ const checks: Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> = [];
365
+
140
366
  const configPath = codexConfigPath(opts.cwd);
141
- let text: string;
367
+ let text: string | null = null;
142
368
  try {
143
369
  text = fs.readFileSync(configPath, "utf-8");
144
370
  } catch {
145
- return [{ surface: "mcp", ok: false, detail: `no codex config at ${configPath}` }];
371
+ checks.push({ surface: "mcp", ok: false, detail: `no codex config at ${configPath}` });
146
372
  }
147
- const expected = expectedCodexBlock(opts.port);
148
- const ok = text.includes(expected);
149
- return [
150
- ok
151
- ? { surface: "mcp", ok: true, detail: `[${CODEX_MCP_SECTION}] block in place` }
152
- : hasOurSection(text)
153
- ? { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block drifted in ${configPath}` }
154
- : { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block missing in ${configPath}` },
155
- ];
373
+ if (text !== null) {
374
+ const expected = expectedCodexBlock(opts.port);
375
+ checks.push(
376
+ text.includes(expected)
377
+ ? { surface: "mcp", ok: true, detail: `[${CODEX_MCP_SECTION}] block in place` }
378
+ : hasOurSection(text)
379
+ ? { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block drifted in ${configPath}` }
380
+ : { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block missing in ${configPath}` },
381
+ );
382
+ }
383
+
384
+ // hooks.json: exactly our entry per event (in-data ownership, shim basename)
385
+ const hooksJson = codexHooksJsonPath(opts.cwd);
386
+ const current = readJsonObject(hooksJson);
387
+ if (current === "absent" || current === null) {
388
+ checks.push({
389
+ surface: "hooks",
390
+ ok: false,
391
+ detail:
392
+ current === null
393
+ ? `hooks.json unreadable as object (${hooksJson})`
394
+ : `hooks.json missing (${hooksJson})`,
395
+ });
396
+ } else {
397
+ const expected = expectedCodexHookEntries(opts.hooksDir);
398
+ const hooks = (current.hooks ?? {}) as Record<string, unknown>;
399
+ const bad: string[] = [];
400
+ for (const event of ["PostToolUse", "SessionStart"] as const) {
401
+ const list: unknown[] = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : [];
402
+ const ours = list.filter(isOurHookEntry);
403
+ if (!(ours.length === 1 && sameJson(ours[0], expected[event]))) bad.push(event);
404
+ }
405
+ checks.push(
406
+ bad.length === 0
407
+ ? { surface: "hooks", ok: true, detail: "our hook entries in place" }
408
+ : { surface: "hooks", ok: false, detail: `hook entries drifted/missing: ${bad.join(", ")} (${hooksJson})` },
409
+ );
410
+
411
+ // trust state — only meaningful when the entries are in place
412
+ if (bad.length === 0) {
413
+ let real: string | null = null;
414
+ try {
415
+ real = fs.realpathSync(hooksJson);
416
+ } catch {
417
+ real = null;
418
+ }
419
+ if (real === null) {
420
+ checks.push({ surface: "trust", ok: false, detail: "hooks.json vanished mid-check" });
421
+ } else {
422
+ const bin = opts.iapeerBin ?? IAPEER_BIN;
423
+ const proc = egress.spawnSync([bin, "trust-hooks", real, "--check"], {
424
+ explicitBin: opts.iapeerBin !== undefined,
425
+ });
426
+ if (proc.refused) {
427
+ checks.push({
428
+ surface: "trust",
429
+ ok: true,
430
+ detail: "trust check skipped (test sandbox)",
431
+ });
432
+ } else if (proc.spawnError) {
433
+ checks.push({
434
+ surface: "trust",
435
+ ok: false,
436
+ detail: `trust state UNKNOWN — ${bin} unavailable (${proc.spawnError}); untrusted hooks skip silently in headless`,
437
+ });
438
+ } else if (proc.exitCode === 0) {
439
+ checks.push({ surface: "trust", ok: true, detail: "hooks trusted (trust-hooks --check)" });
440
+ } else {
441
+ checks.push({
442
+ surface: "trust",
443
+ ok: false,
444
+ detail:
445
+ `hooks NOT trusted (drift/missing per trust-hooks --check): ` +
446
+ `${(proc.stdout.trim() || proc.stderr.trim()).slice(0, 160)} — repair: provision-peer / update`,
447
+ });
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ return checks;
156
454
  }
@@ -22,6 +22,7 @@ import {
22
22
  type SurfaceOutcome,
23
23
  } from "./claude.js";
24
24
  import { checkCodexPeer, provisionCodexPeer, unprovisionCodexPeer } from "./codex.js";
25
+ import type { Egress } from "../egress.js";
25
26
  import type { FleetPeer } from "../fleet.js";
26
27
 
27
28
  export const SESSION_RUNTIMES = ["claude", "codex"] as const;
@@ -46,11 +47,15 @@ function sessionRuntimesOf(peer: FleetPeer): SessionRuntime[] {
46
47
  return SESSION_RUNTIMES.filter((r) => peer.runtimes.includes(r));
47
48
  }
48
49
 
49
- export function sweepProvision(opts: {
50
- fleet: FleetPeer[];
51
- hooksDir: string;
52
- port: number;
53
- }): SweepSummary {
50
+ export function sweepProvision(
51
+ egress: Egress,
52
+ opts: {
53
+ fleet: FleetPeer[];
54
+ hooksDir: string;
55
+ port: number;
56
+ iapeerBin?: string;
57
+ },
58
+ ): SweepSummary {
54
59
  const results: PeerSweepResult[] = [];
55
60
  const skipped: SweepSummary["skipped"] = [];
56
61
  for (const peer of opts.fleet) {
@@ -71,7 +76,12 @@ export function sweepProvision(opts: {
71
76
  for (const runtime of runtimes) {
72
77
  const outcomes =
73
78
  runtime === "codex"
74
- ? provisionCodexPeer({ cwd: peer.cwd, port: opts.port })
79
+ ? provisionCodexPeer(egress, {
80
+ cwd: peer.cwd,
81
+ port: opts.port,
82
+ hooksDir: opts.hooksDir,
83
+ iapeerBin: opts.iapeerBin,
84
+ })
75
85
  : provisionClaudePeer({
76
86
  cwd: peer.cwd,
77
87
  hooksDir: opts.hooksDir,
@@ -125,11 +135,15 @@ export type PeerCheckResult = {
125
135
  problems: string[];
126
136
  };
127
137
 
128
- export function checkFleetSurfaces(opts: {
129
- fleet: FleetPeer[];
130
- hooksDir: string;
131
- port: number;
132
- }): { checks: PeerCheckResult[]; skipped: Array<{ personality: string; reason: string }> } {
138
+ export function checkFleetSurfaces(
139
+ egress: Egress,
140
+ opts: {
141
+ fleet: FleetPeer[];
142
+ hooksDir: string;
143
+ port: number;
144
+ iapeerBin?: string;
145
+ },
146
+ ): { checks: PeerCheckResult[]; skipped: Array<{ personality: string; reason: string }> } {
133
147
  const checks: PeerCheckResult[] = [];
134
148
  const skipped: Array<{ personality: string; reason: string }> = [];
135
149
  for (const peer of opts.fleet) {
@@ -150,7 +164,12 @@ export function checkFleetSurfaces(opts: {
150
164
  for (const runtime of runtimes) {
151
165
  const surfaceChecks =
152
166
  runtime === "codex"
153
- ? checkCodexPeer({ cwd: peer.cwd, port: opts.port })
167
+ ? checkCodexPeer(egress, {
168
+ cwd: peer.cwd,
169
+ port: opts.port,
170
+ hooksDir: opts.hooksDir,
171
+ iapeerBin: opts.iapeerBin,
172
+ })
154
173
  : checkClaudePeer({
155
174
  cwd: peer.cwd,
156
175
  hooksDir: opts.hooksDir,
@@ -51,13 +51,17 @@ different world.
51
51
  scriber thread stalled: place the stale drafts UNVETTED by the usual
52
52
  rules; \`needs_review: true\` already travels with each file. The
53
53
  Scriber re-vets them with the next PERMANENT_BATCH once alive.
54
- - **DREAM_TICK** (notifier timer, weekly) — fan out DreamWeaver over the
55
- agent-memory subfolders (including your own), strictly one folder per
56
- task, sequentially. DreamWeaver takes tasks ONLY from you (the one
57
- exception: a folder's owner may task it on their own folder); put
58
- everything it needs INTO the task. Task: \`{agent, path, mode,
59
- transcripts_window_days}\` consolidation report; archive what it
60
- deprecated, act on its \`attention\` blocks yourself.
54
+ - **DREAM_TICK** (notifier timer, weekly) — run \`iapeer-memory
55
+ dream-paths\` (read-only; the LIVE registry at tick time) and fan out
56
+ DreamWeaver over the folders of its output (including your own),
57
+ strictly one folder per task, sequentially. DreamWeaver takes tasks ONLY
58
+ from you (the one exception: a folder's owner may task it on their own
59
+ folder). Task: \`{agent, path, mode, transcripts_window_days,
60
+ transcripts}\` copy \`transcripts\` from the verb's output AS IS
61
+ (globs + the codex cwdFilter; path forms are the code's zone, not
62
+ yours). A verb error = report to the owner, never guess the fleet. On
63
+ the consolidation report: archive what it deprecated, act on its
64
+ \`attention\` blocks yourself.
61
65
  - **Direct IAP** from agents or the human — structure questions; never
62
66
  run searches for others (they have their own vault tools).
63
67
 
@@ -237,7 +241,7 @@ on-demand from a folder's OWNER for their own folder only. One task = one
237
241
  clean window = ONE outbound message (the final consolidation report to the
238
242
  task sender). Discipline: touch ONLY the folder named in the task.
239
243
 
240
- Task: \`{agent, path, mode, transcripts_window_days}\`.
244
+ Task: \`{agent, path, mode, transcripts_window_days, transcripts}\`.
241
245
 
242
246
  ## The four phases
243
247
 
@@ -253,11 +257,14 @@ Task: \`{agent, path, mode, transcripts_window_days}\`.
253
257
  mentions in bodies; read the targets; on a clear mismatch (file gone,
254
258
  function renamed) write an updated note and flip the old one to the
255
259
  outdated token. LOCAL checks only.
256
- - **D — Transcript scan.** Read the runtime's session transcripts for the
257
- window (\`transcripts_window_days\`, adapter-scoped paths; no paths/empty
258
- glob skip the phase). Find user phrases that formulate a rule with 2+
259
- explicit confirmations in different sessions; check against existing
260
- feedback notes; write new notes with quotes for what's missing.
260
+ - **D — Transcript scan.** Read the session transcripts for the window
261
+ (\`transcripts_window_days\`) per the task's \`transcripts\`: each entry
262
+ is a glob; for \`runtime: codex\` the store is HOST-WIDE take ONLY the
263
+ sessions whose \`session_meta.cwd\` equals the entry's \`cwdFilter\`
264
+ (foreign cwds are foreign memory). No entries / empty glob → skip the
265
+ phase. Find user phrases that formulate a rule with 2+ explicit
266
+ confirmations in different sessions; check against existing feedback
267
+ notes; write new notes with quotes for what's missing.
261
268
 
262
269
  ## Hard limits
263
270
 
@@ -44,13 +44,16 @@ locale: ru
44
44
  НЕВЫЧИТАННЫМИ по обычным правилам; \`needs_review: true\` уже едет с
45
45
  каждым файлом. Scriber довычитает их со следующей PERMANENT_BATCH,
46
46
  когда оживёт.
47
- - **DREAM_TICK** (notifier-таймер, еженедельно) — fan-out DreamWeaver по
48
- подпапкам оперативки (включая твою), строго одна папка на задачу,
49
- последовательно. DreamWeaver берёт задачи ТОЛЬКО от тебя (единственное
50
- исключение: владелец папки на свою собственную); клади в задачу всё
51
- необходимое. Задача: \`{agent, path, mode, transcripts_window_days}\`
52
- отчёт консолидации; архивируй устаревшее по его отчёту, его
53
- \`attention\`-блоки отрабатывай сам.
47
+ - **DREAM_TICK** (notifier-таймер, еженедельно) — выполни
48
+ \`iapeer-memory dream-paths\` (read-only; живой реестр на момент тика) и
49
+ fan-out DreamWeaver по папкам его вывода (включая твою), строго одна
50
+ папка на задачу, последовательно. DreamWeaver берёт задачи ТОЛЬКО от
51
+ тебя (единственное исключение: владелец папки на свою собственную).
52
+ Задача: \`{agent, path, mode, transcripts_window_days, transcripts}\`
53
+ \`transcripts\` перекладывай из вывода verb'а КАК ЕСТЬ (глобы + codex
54
+ cwdFilter; формы путей — зона кода, не твоя). Ошибка verb'а = доложи
55
+ владельцу, флот не угадывай. По отчёту консолидации архивируй
56
+ устаревшее, \`attention\`-блоки отрабатывай сам.
54
57
  - **Прямые IAP** от агентов и человека — вопросы структуры; чужие поиски
55
58
  не выполняешь (у агентов свои vault-тулы).
56
59
 
@@ -224,7 +227,7 @@ on-demand от ВЛАДЕЛЬЦА папки — только на его соб
224
227
  одно чистое окно = ОДНО исходящее (финальный отчёт консолидации
225
228
  постановщику). Дисциплина: трогай ТОЛЬКО папку из задачи.
226
229
 
227
- Задача: \`{agent, path, mode, transcripts_window_days}\`.
230
+ Задача: \`{agent, path, mode, transcripts_window_days, transcripts}\`.
228
231
 
229
232
  ## Четыре фазы
230
233
 
@@ -239,11 +242,14 @@ on-demand от ВЛАДЕЛЬЦА папки — только на его соб
239
242
  env-переменных; прочитай цели; при явном расхождении (файла нет, функция
240
243
  переименована) — новая updated-заметка + старая в «устарело». Только
241
244
  ЛОКАЛЬНЫЕ проверки.
242
- - **D — Скан транскриптов.** Прочитай транскрипты сессий рантайма за окно
243
- (\`transcripts_window_days\`, adapter-scoped пути; путей нет / glob пуст —
244
- фаза пропускается). Найди user-фразы, формулирующие правило, с 2+ явными
245
- подтверждениями в разных сессиях; сверь с существующими feedback-заметками;
246
- недостающееновой заметкой с цитатами.
245
+ - **D — Скан транскриптов.** Прочитай транскрипты сессий за окно
246
+ (\`transcripts_window_days\`) по \`transcripts\` из задачи: для каждой
247
+ записи glob; у \`runtime: codex\` хранилище HOST-WIDE, бери ТОЛЬКО
248
+ сессии, чей \`session_meta.cwd\` равен \`cwdFilter\` записи (чужие cwd —
249
+ чужая память). Записей нет / glob пуст фаза пропускается. Найди
250
+ user-фразы, формулирующие правило, с 2+ явными подтверждениями в разных
251
+ сессиях; сверь с существующими feedback-заметками; недостающее — новой
252
+ заметкой с цитатами.
247
253
 
248
254
  ## Жёсткие границы
249
255
 
package/src/watcher.ts CHANGED
@@ -201,9 +201,12 @@ export function dreamTimerMessage(opts?: {
201
201
  return JSON.stringify({
202
202
  when: opts?.cron ?? "0 4 * * 1",
203
203
  message:
204
- "DREAM_TICK: weekly agent-memory consolidation. Fan out DreamWeaver " +
205
- "over the agent-memory subfolders (including your own), strictly one " +
206
- "folder per task, sequentially per your doctrine.",
204
+ "DREAM_TICK: weekly agent-memory consolidation. Run `iapeer-memory " +
205
+ "dream-paths` (read-only) and fan out DreamWeaver over its folders — " +
206
+ "strictly one folder per task, sequentially, carrying that folder's " +
207
+ "`transcripts` (globs + codex cwdFilter) into the task — per your " +
208
+ "doctrine. The verb resolves the LIVE registry at tick time; an error " +
209
+ "line from it = report, do not guess the fleet.",
207
210
  target: opts?.target ?? "index",
208
211
  id: opts?.id ?? DREAM_TRIGGER_ID,
209
212
  });