@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 +2 -2
- package/src/cli.ts +12 -4
- package/src/commands/dream-paths.ts +153 -0
- package/src/commands/hook.ts +76 -12
- package/src/commands/init.ts +1 -1
- package/src/commands/provision-peer.ts +16 -5
- package/src/commands/update.ts +1 -1
- package/src/commands/verify.ts +2 -2
- package/src/fleet.ts +49 -42
- package/src/surfaces/claude.ts +13 -6
- package/src/surfaces/codex.ts +321 -23
- package/src/surfaces/sweep.ts +31 -12
- package/src/templates/roles-en.ts +20 -13
- package/src/templates/roles-ru.ts +19 -13
- package/src/watcher.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory",
|
|
3
|
-
"version": "0.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.
|
|
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
|
-
|
|
57
|
-
into this at
|
|
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
|
+
}
|
package/src/commands/hook.ts
CHANGED
|
@@ -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
|
|
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: {
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
125
|
-
// an Edit-loop
|
|
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" &&
|
|
193
|
+
tool === "Write" && files[0]!.startsWith(ownMemoryDir)
|
|
130
194
|
? JSON.stringify({
|
|
131
195
|
hookSpecificOutput: {
|
|
132
196
|
hookEventName: "PostToolUse",
|
package/src/commands/init.ts
CHANGED
|
@@ -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`
|
|
23
|
-
* hooks
|
|
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(
|
|
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,
|
package/src/commands/update.ts
CHANGED
|
@@ -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);
|
package/src/commands/verify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
package/src/surfaces/claude.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/surfaces/codex.ts
CHANGED
|
@@ -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
|
|
33
|
-
* deliberately
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
*
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
371
|
+
checks.push({ surface: "mcp", ok: false, detail: `no codex config at ${configPath}` });
|
|
146
372
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
}
|
package/src/surfaces/sweep.ts
CHANGED
|
@@ -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(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
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(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
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) —
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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-таймер, еженедельно) —
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
\`
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
205
|
-
"
|
|
206
|
-
"folder per task, sequentially
|
|
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
|
});
|