@agfpd/iapeer-memory 0.2.1 → 0.2.3

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.1",
3
+ "version": "0.2.3",
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.1"
30
+ "@agfpd/iapeer-memory-core": "0.2.3"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/bun": "^1.2.0",
package/src/cli.ts CHANGED
@@ -52,9 +52,10 @@ Commands:
52
52
  init step / repair path; needs package sources
53
53
  provision-peer --cwd P --runtime claude|codex --personality NAME [--occasion O]
54
54
  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
55
+ cwd (claude: hooks/MCP/skills; codex: project MCP +
56
+ hooks.json with a trust-hooks pre-seed; idempotent,
57
+ own keys only); the iapeer core shells into this at
58
+ peer birth
58
59
  unprovision-peer --cwd P --runtime claude|codex [--occasion O]
59
60
  strip OUR surfaces from one peer's cwd (mirror)
60
61
  fm-update [ops] FILE... structural frontmatter edits + attribution stamp
@@ -125,7 +126,7 @@ export async function main(argv: string[]): Promise<number> {
125
126
  case "install-binary":
126
127
  return cmdInstallBinary(rest, egress);
127
128
  case "provision-peer":
128
- return cmdProvisionPeer(rest);
129
+ return cmdProvisionPeer(rest, egress);
129
130
  case "unprovision-peer":
130
131
  return cmdUnprovisionPeer(rest);
131
132
  case "fm-update":
@@ -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 });
@@ -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,
@@ -69,6 +69,12 @@ step, when ALL THREE hold: the Scriber processed the note + the links
69
69
  section is complete + no open questions remain (no unanswered author
70
70
  pings). Nobody sets it by decision; nobody else clears it.
71
71
 
72
+ \`last_edited_by: unstamped\` — the write BYPASSED the hook (a Bash write,
73
+ an external editor): memoryd's detector honestly says «writer unknown»
74
+ instead of a silently inherited attribution. Resolve it by context (the
75
+ content, git, asking the writers); \`needs_review\` is already set — clear
76
+ it under the usual three conditions.
77
+
72
78
  ## Agent-memory curation (light, no Scriber)
73
79
 
74
80
  1. \`status\` is final → move to the archive subfolder; stop.
@@ -62,6 +62,12 @@ locale: ru
62
62
  дополнена + открытых вопросов нет (все пинги авторам закрыты). Никто не
63
63
  ставит флаг решением; никто кроме тебя не снимает.
64
64
 
65
+ \`last_edited_by: unstamped\` — запись прошла МИМО хука (Bash-запись,
66
+ внешний редактор): детектор memoryd честно говорит «писатель неизвестен»
67
+ вместо тихо унаследованной атрибуции. Разбирайся по контексту (содержание,
68
+ git, вопрос писателям); needs_review при этом уже стоит — снимаешь по
69
+ обычным трём условиям.
70
+
65
71
  ## Курирование оперативки (лёгкое, без Scriber'а)
66
72
 
67
73
  1. Финальный \`status\` → move в архивную подпапку; дальше не обрабатывай.