@staix/agent-mem 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,86 +1,138 @@
1
1
  import type { BriefOptions } from "./session-brief.ts";
2
2
  import type { CaptureStore } from "./memory/capture.ts";
3
- import { resolveHookProjectAsync } from "./hook-project.ts";
4
- import { hookPeakRssKB } from "./hook-resources.ts";
3
+ import { acceptHookProjectReceipt } from "./hook-project.ts";
4
+ import { writeSync } from "node:fs";
5
+ import { hookRssReading, hookPeakRssKB } from "./hook-resources.ts";
6
+ import { rssSourceCode } from "./hook-rss-wire.ts";
5
7
  import { resolveAlias } from "./memory/aliases.ts";
6
- import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
7
- import { drainHookCaptures, hookCaptureScope, quarantineHookScope } from "./hook-spool.ts";
8
- import { createStandalonePolicy } from "./memory/policy.ts";
9
- import { configuredPolicyOptions } from "./memory/config.ts";
8
+ import { HOOK_EVENTS, HOOK_INPUT_BYTES, type HookTranscriptProgress } from "./hook-contract.ts";
9
+ import { drainHookCaptures, hookCaptureScope, hookReplayBudget } from "./hook-spool.ts";
10
+ /** Existing allowance for the scan's own cursor commit; the parent also retains its cleanup reserve. */
11
+ const SCAN_COMMIT_MS = 20;
10
12
 
11
- async function readOptions(): Promise<string> {
12
- const reader = Bun.stdin.stream().getReader(), chunks: Uint8Array[] = []; let bytes = 0;
13
- try {
13
+ /** #226: options arrive as newline-delimited JSON. A transcript hook first sends the scan options, then (after native
14
+ * metadata) the drain options; other hooks send only the drain options. Total input stays bounded. */
15
+ function optionLines(): () => Promise<string | undefined> {
16
+ const reader = Bun.stdin.stream().getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
17
+ let pending = "", bytes = 0, done = false;
18
+ return async () => {
14
19
  for (;;) {
15
- const next = await reader.read(); if (next.done) break;
20
+ const at = pending.indexOf("\n");
21
+ if (at >= 0) { const line = pending.slice(0, at); pending = pending.slice(at + 1); return line; }
22
+ if (done) { const rest = pending; pending = ""; return rest || undefined; }
23
+ const next = await reader.read();
24
+ if (next.done) { done = true; pending += decoder.decode(); continue; }
16
25
  bytes += next.value.byteLength;
17
- // Native input is at most64KiB; leave bounded room for fixed host options and policy literals.
18
- if (bytes > HOOK_INPUT_BYTES + 32 * 1024) throw new Error("input bound");
19
- chunks.push(next.value);
26
+ // Native input is at most 64 KiB, sent at most twice (scan, then drain); leave bounded room for fixed options.
27
+ if (bytes > 2 * HOOK_INPUT_BYTES + 32 * 1024) throw new Error("input bound");
28
+ pending += decoder.decode(next.value, { stream: true });
20
29
  }
21
- return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
22
- } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
30
+ };
23
31
  }
24
32
 
25
33
  // Fixed package helper, launched and reaped by each native hook; never registered or resident.
26
34
  if (import.meta.main) {
27
- let owned = false;
28
- const nativeUsage = { rssKB: 0, rawRssKB: 0 };
29
- const stop = (): never => {
30
- // This leader is still alive in the detached group created by hookWorker.
31
- // Native Git uses a separate owned async supervisor with its own native watchdog;
32
- // a blocked Git cannot prevent this worker's JS deadline from firing.
33
- if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* No group exists in a direct, non-owned invocation. */ }
35
+ let owned = false, rssTelemetryValid = true;
36
+ let lastProgress: HookTranscriptProgress | undefined;
37
+ const frame = (rss: number[]) => { try { writeSync(1, JSON.stringify({ rss }) + "\n"); } catch { rssTelemetryValid = false; } };
38
+ const progress = (transcript: HookTranscriptProgress) => {
39
+ lastProgress = { ...lastProgress, ...transcript, ...(lastProgress?.stopReason === "unavailable" ? { stopReason: "unavailable" as const } : {}) };
40
+ try { writeSync(1, JSON.stringify({ transcript: lastProgress }) + "\n"); } catch { rssTelemetryValid = false; }
41
+ };
42
+ const drain = (value: { startMs?: number; drained: number }) => { try { writeSync(1, JSON.stringify({ drain: value }) + "\n"); } catch { rssTelemetryValid = false; } };
43
+ frame([0]);
44
+ const stop: () => never = () => {
45
+ if (!rssTelemetryValid) frame([4]);
46
+ const self = hookRssReading(); frame([3, self.kb, self.rawKB, rssSourceCode(self)]);
47
+ if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* A direct unowned invocation has no group. */ }
34
48
  process.exit(0);
35
49
  };
36
50
  const emit = async (result: unknown) => {
37
- const body = JSON.stringify({ ...(result as object), complete: true });
38
- const rssKB = hookPeakRssKB() + nativeUsage.rssKB;
51
+ const body = JSON.stringify({ ...(result as object), complete: true, rssTelemetryValid });
52
+ const rssKB = hookPeakRssKB();
39
53
  await Bun.stdout.write(body.slice(0, -1) + `,"rssKB":${rssKB}}\n`);
40
54
  stop();
41
55
  };
56
+ // Ordinary hooks preload only the store, as before. They must not pay for a transcript module they never use.
57
+ let captureReady = false;
58
+ const captureModule = import("./memory/capture.ts");
59
+ captureModule.then(() => { captureReady = true; }, () => { /* Reported when the final drain awaits it. */ });
60
+ const next = optionLines(), now = () => performance.timeOrigin + performance.now();
61
+ let store: CaptureStore | undefined;
42
62
  try {
43
- const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; deadlineAt: number; ownedGroup: boolean };
44
- if (!Number.isFinite(options.deadlineAt) || options.ownedGroup !== true) process.exit(0);
63
+ const line = await next();
64
+ if (line === undefined) stop();
65
+ const first = JSON.parse(line) as Record<string, unknown>;
66
+ type Scan = { cwd: string; base: string; transcript: unknown; policy?: BriefOptions["policy"]; deadlineAt: number };
67
+ type DrainOptions = BriefOptions & { current: string; event: string; identity?: unknown; deadlineAt: number; ownedGroup: boolean; hookStartAt?: number };
68
+ const scan = first.scan as Scan | undefined;
69
+ const deadlineAt = scan?.deadlineAt ?? first.deadlineAt;
70
+ if (!Number.isFinite(deadlineAt) || first.ownedGroup !== true) stop();
45
71
  owned = true;
46
- const remaining = () => options.deadlineAt - (performance.timeOrigin + performance.now());
47
- if (remaining() <= 0) stop();
48
- setTimeout(stop, remaining()).unref();
49
- const policy = createStandalonePolicy(configuredPolicyOptions(options.base!, options.cwd, options.policy)), event = options.event;
50
- if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
51
- const expectedScope = hookCaptureScope(options.cwd, options.base!);
52
- let project: string;
53
- // Overlap the shared resolver's existing origin/metadata reads on a cold cache;
54
- // serial native launches can exhaust every hook before any binding or replay is published.
55
- // ponytail: cold identity must still fit one hook deadline. Revalidated durable
56
- // identity progress is the upgrade if slower native reads keep exhausting that slice.
57
- try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart", prefetch: true }, nativeUsage)); }
58
- catch {
59
- if (expectedScope !== undefined) quarantineHookScope({ cwd: options.cwd, base: options.base!, scope: expectedScope });
60
- project = stop();
61
- }
62
- let captured = false;
63
- // Normal SessionStart keeps the parent's durable stage for later replay and spends
64
- // its short-lived worker on context. An empty current name is mandatory full-tier recovery.
65
- if (expectedScope !== undefined && (event !== "SessionStart" || !options.current)) {
66
- const { CaptureStore } = await import("./memory/capture.ts");
67
- let store: CaptureStore | undefined;
68
- try {
69
- const drained = await drainHookCaptures({ ...options, base: options.base!, project, expectedScope, timeRemaining: remaining }, (input, receipt) => {
70
- store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0, cwd: options.cwd });
71
- return store.append(input, { receipt });
72
- });
73
- captured = drained.captured.includes(options.current);
74
- } finally { store?.close(); }
75
- }
76
- if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
77
- if (event === "SessionStart") {
78
- const { sessionStartBrief } = await import("./session-brief.ts");
79
- const result = await sessionStartBrief({ ...options, project,
80
- timeRemaining: () => options.deadlineAt - (performance.timeOrigin + performance.now()) });
81
- const valid = await policy.screen({ context: result.context });
82
- if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
83
- await emit({ ...result, captured, valid });
84
- } else await emit({ context: "", captured, valid: true });
72
+ const end = deadlineAt as number;
73
+ if (now() >= end) { if (scan) progress({ stopReason: "deadline" }); stop(); }
74
+ setTimeout(() => { if (scan) progress({ stopReason: "deadline" }); stop(); }, end - now()).unref();
75
+
76
+ // Read the native receipt while scan setup runs. Publication callbacks await it only when replay can proceed.
77
+ const drainLine = scan ? next() : Promise.resolve(line);
78
+ const ready = (async () => {
79
+ const text = await drainLine;
80
+ if (text === undefined) return undefined;
81
+ const options = JSON.parse(text) as DrainOptions;
82
+ if (options.ownedGroup !== true || options.deadlineAt !== end || !(HOOK_EVENTS as readonly string[]).includes(options.event) || options.event === "SessionStart") return undefined;
83
+ const expectedScope = hookCaptureScope(options.cwd, options.base!);
84
+ let project: string | undefined;
85
+ try { project = resolveAlias(options.base!, acceptHookProjectReceipt(options.identity, { cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: () => end - now() })); } catch { /* A bound scan may still stage without a project. */ }
86
+ return { options, expectedScope, project };
87
+ })();
88
+ let receiptReady = false;
89
+ ready.then(() => { receiptReady = true; }, () => { /* The final drain reports failure inside the owned helper. */ });
90
+ const budget = hookReplayBudget();
91
+ let drainedCount = 0, captured = false, drainStarted = false;
92
+ const replay = async (until: number, waitForReady = true) => {
93
+ // Scanning must not wait on native metadata or cold store imports between summary lines.
94
+ // The final replay may await them; publication/backpressure callbacks only use ready resources.
95
+ if (!waitForReady && (!receiptReady || !captureReady)) return false;
96
+ if (!budget.files || !budget.bytes || until - now() < 8) return false;
97
+ const context = await ready;
98
+ if (!context?.project || context.expectedScope === undefined || Math.min(end, until) - now() < 8) return false;
99
+ const { options, expectedScope, project } = context;
100
+ const { CaptureStore } = await captureModule;
101
+ const remaining = () => Math.min(end, until) - now();
102
+ if (remaining() < 8) return false;
103
+ if (!drainStarted) {
104
+ drain({ startMs: Math.round(now() - (options.hookStartAt ?? end)), drained: drainedCount });
105
+ drainStarted = true;
106
+ }
107
+ const drained = await drainHookCaptures({ ...options, base: options.base!, project, expectedScope, timeRemaining: remaining, replayBudget: budget }, (input, receipt) => {
108
+ store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0, cwd: options.cwd });
109
+ const appended = store.append(input, { receipt });
110
+ drain({ drained: ++drainedCount }); // DB commit or durable capture-spool acknowledgement, as in the existing contract.
111
+ return appended;
112
+ });
113
+ captured ||= drained.captured.includes(options.current);
114
+ return drained.captured.length > 0;
115
+ };
116
+ let context: Awaited<typeof ready>;
117
+ try {
118
+ if (scan && hookCaptureScope(scan.cwd, scan.base) !== undefined) {
119
+ const scanEnd = end - SCAN_COMMIT_MS;
120
+ if (scanEnd - now() < 5) progress({ stopReason: "deadline" });
121
+ else try {
122
+ const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
123
+ const scanned = await scanHookTranscriptTail(scan.transcript, {
124
+ cwd: scan.cwd, base: scan.base, policy: scan.policy, deadline: performance.now() + (scanEnd - now()),
125
+ // Same existing oldest-first drain and one shared budget, never a new 32-file allowance per publication.
126
+ onPublication: async () => { await replay(scanEnd, false); },
127
+ onBackpressure: () => replay(scanEnd, false),
128
+ });
129
+ if (scanned) progress(scanned);
130
+ } catch { progress({ stopReason: "unavailable" }); }
131
+ }
132
+ context = await ready;
133
+ await replay(end); // Newly staged summaries get their final commit opportunity in this hook too.
134
+ } finally { store?.close(); }
135
+ if (!context?.project || hookCaptureScope(context.options.cwd, context.options.base!) !== context.expectedScope) stop();
136
+ await emit({ context: "", captured, valid: true });
85
137
  } catch { if (owned) stop(); process.exitCode = 1; }
86
138
  }
@@ -152,13 +152,13 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
152
152
  let candidateLine: string | undefined;
153
153
  const line = pendingLine(pendingSummary(base, [project, ...aliases], (options.now ?? Date.now)()));
154
154
  if (line !== undefined && Buffer.byteLength(line) + 1 <= remaining) { remaining -= Buffer.byteLength(line) + 1; candidateLine = line; }
155
- const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean) => {
156
- if (!await screen(shown.data)) return;
155
+ const add = async (section: Section, shown: { data: unknown; partial: boolean }, evidence: boolean): Promise<boolean> => {
156
+ if (!await screen(shown.data)) return false;
157
157
  coverage[section] = shown.partial ? "partial" : coverage[section] ?? "complete";
158
158
  const line = JSON.stringify({ section, evidence, data: shown.data });
159
159
  const size = Buffer.byteLength(line) + 1;
160
- if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return; }
161
- lines.push(line); remaining -= size;
160
+ if (Buffer.byteLength(line) > RECORD_BYTES || size > remaining) { coverage[section] = "partial"; return false; }
161
+ lines.push(line); remaining -= size; return true;
162
162
  };
163
163
  const run = async (section: Section, work: () => Promise<void>) => {
164
164
  const firstLine = lines.length, priorRemaining = remaining;
@@ -193,14 +193,15 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
193
193
  }
194
194
  });
195
195
  const recent = async <T>(section: "curated" | "capture", read: (window: ReadWindow) => { rows: T[]; next?: ReadRef },
196
- display: (row: T) => { data: unknown; partial: boolean }) => {
197
- let after: ReadWindow["after"], selected = 0, complete = false;
196
+ display: (row: T) => { data: unknown; partial: boolean }, selectedBefore = 0, skipCaptureIds: ReadonlySet<number> = new Set()) => {
197
+ let after: ReadWindow["after"], selected = selectedBefore, complete = false;
198
198
  for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber++) {
199
199
  guard();
200
200
  if (options.nativeSession && options.timeRemaining && options.timeRemaining() <= 0) throw new Error("brief deadline");
201
201
  const page = read({ project, limit: PAGE_ROWS, after });
202
202
  for (const row of page.rows) {
203
203
  if ((row as { project?: unknown }).project !== project) { coverage[section] = "partial"; continue; }
204
+ if (section === "capture" && skipCaptureIds.has((row as { id: number }).id)) continue;
204
205
  // #173: subagent capture stays searchable but never enters the session brief.
205
206
  if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
206
207
  if (!await screen(row)) continue;
@@ -249,7 +250,18 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
249
250
  }
250
251
  const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession || options.measureOnly ? { readOnly: true, busyTimeoutMs: options.measureOnly ? 1000 : 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close());
251
252
  if (capture.recentReadTruncated) coverage.capture = "partial";
252
- await recent("capture", window => capture.readPage(window), captureDisplay);
253
+ const summaries = capture.readBriefSummaries(), summaryIds = new Set<number>();
254
+ if (summaries.partial) coverage.capture = "partial";
255
+ let selectedSummaries = 0;
256
+ for (const row of summaries.rows) {
257
+ if (row.project !== project) continue;
258
+ if (row.subagent === true) { summaryIds.add(row.id); continue; }
259
+ if (selectedSummaries >= 2) break;
260
+ if (!await screen(row)) { summaryIds.add(row.id); continue; }
261
+ summaryIds.add(row.id);
262
+ if (await add("capture", captureDisplay(row), false)) selectedSummaries++;
263
+ }
264
+ await recent("capture", window => capture.readPage(window), captureDisplay, selectedSummaries, summaryIds);
253
265
  });
254
266
  guard();
255
267
  const context = [header(coverage, reasons), ...(candidateLine ? [candidateLine] : []), ...lines].join("\n");
@@ -0,0 +1,217 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import type { SetupTarget } from "./setup.ts";
6
+ import { SETUP_CONFIG_BYTES } from "./setup-toml.ts";
7
+ import { managedAfter, managedHookEntries, mcpRecord, type ConfigImage } from "./setup-owned.ts";
8
+ import { resolveSetupPaths, type SetupEnvironment } from "./setup-paths.ts";
9
+ import { HOOK_EVENTS } from "./hook-contract.ts";
10
+ import { realPath } from "./memory/platform.ts";
11
+
12
+ export type SetupDriftStatus = "ok" | "attention" | "skipped" | "unavailable";
13
+ export interface SetupDriftEntry {
14
+ target: SetupTarget;
15
+ file: string;
16
+ entry: string;
17
+ reason: string;
18
+ kind: "drift" | "unavailable" | "review-needed";
19
+ expectedFile?: string;
20
+ }
21
+ export interface SetupDriftReport {
22
+ status: SetupDriftStatus;
23
+ checked: number;
24
+ entries: SetupDriftEntry[];
25
+ reason?: string;
26
+ }
27
+
28
+ interface ReceiptChange { key: string; path: string; before: ConfigImage; after: ConfigImage }
29
+ interface Receipt { version: 1; target: SetupTarget; phase: "prepared" | "applied"; changes: ReceiptChange[] }
30
+ const JOURNAL_BYTES = 12 * SETUP_CONFIG_BYTES;
31
+ // ponytail: at most 32 MiB of receipt/config input per doctor call; use metadata-only receipts before widening.
32
+ const READ_BYTES = 32 * 1024 * 1024;
33
+ const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
34
+ const plain = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
35
+
36
+ function safeImage(path: string, max = SETUP_CONFIG_BYTES, budget?: { remaining: number }): ConfigImage {
37
+ let cursor = resolve(path);
38
+ while (true) {
39
+ try { if (lstatSync(cursor).isSymbolicLink()) throw Error("unsafe"); }
40
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
41
+ const parent = dirname(cursor); if (parent === cursor) break; cursor = parent;
42
+ }
43
+ let fd: number;
44
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
45
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { bytes: null, mode: 0o600 }; throw error; }
46
+ try {
47
+ const before = fstatSync(fd);
48
+ if (!before.isFile() || before.nlink !== 1 || before.size > max || budget && before.size > budget.remaining) throw Error("unsafe");
49
+ if (budget) budget.remaining -= before.size;
50
+ const data = Buffer.alloc(before.size + 1), size = readSync(fd, data, 0, data.length, 0), after = fstatSync(fd);
51
+ if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ino !== before.ino || after.dev !== before.dev) throw Error("changed");
52
+ return { bytes: data.subarray(0, size).toString("base64"), mode: before.mode & 0o777 };
53
+ } finally { closeSync(fd); }
54
+ }
55
+
56
+ function parseImage(value: unknown): ConfigImage {
57
+ if (!plain(value) || !Number.isInteger(value.mode) || Number(value.mode) < 0 || Number(value.mode) > 0o777) throw Error("invalid receipt");
58
+ if (value.bytes !== null && (typeof value.bytes !== "string" || value.bytes.length > 2 * SETUP_CONFIG_BYTES || Buffer.from(value.bytes, "base64").toString("base64") !== value.bytes)) throw Error("invalid receipt");
59
+ return { bytes: value.bytes as string | null, mode: Number(value.mode) };
60
+ }
61
+
62
+ function parseReceipt(value: unknown, target: SetupTarget, expectedPaths: readonly string[]): Receipt {
63
+ if (!plain(value) || value.version !== 1 || value.target !== target || !/^[a-f0-9]{64}$/.test(String(value.intent)) || !["applied", "prepared"].includes(String(value.phase)) || !Array.isArray(value.changes) || value.changes.length !== expectedPaths.length && !(target === "jcode" && value.changes.length === 1)) throw Error("invalid receipt");
64
+ const changes: ReceiptChange[] = [];
65
+ for (let index = 0; index < value.changes.length; index++) {
66
+ const item: unknown = value.changes[index];
67
+ if (!plain(item) || item.key !== `${target}:${index}` || typeof item.path !== "string" || !isAbsolute(item.path) || item.path.length > 4096 || item.path.includes("\0")) throw Error("invalid receipt");
68
+ changes.push({ key: `${target}:${index}`, path: item.path, before: parseImage(item.before), after: parseImage(item.after) });
69
+ }
70
+ return { version: 1, target, phase: value.phase as Receipt["phase"], changes };
71
+ }
72
+
73
+ function workspaceRoot(cwd: string): string | undefined {
74
+ let current = realPath(resolve(cwd));
75
+ while (true) {
76
+ try {
77
+ const marker = lstatSync(join(current, ".git"));
78
+ if (marker.isDirectory() || marker.isFile()) return realPath(current);
79
+ } catch { /* Continue toward the filesystem root. */ }
80
+ const parent = dirname(current); if (parent === current) return undefined; current = parent;
81
+ }
82
+ }
83
+
84
+ const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
85
+ function launch(record: unknown): string | undefined {
86
+ if (!plain(record) || typeof record.command !== "string" || !Array.isArray(record.args) || !record.args.every(arg => typeof arg === "string")) return undefined;
87
+ const args = [...record.args as string[]];
88
+ if (args.at(-1) === "mcp") args.pop();
89
+ return [shellQuote(record.command), ...args.map(arg => arg === "--smol" ? arg : shellQuote(arg))].join(" ");
90
+ }
91
+
92
+ function expectedManagedHookCommands(target: SetupTarget, after: ConfigImage): { event: string; command: string }[] {
93
+ if (["jcode", "opencode", "gemini", "agy"].includes(target)) return [];
94
+ const base = launch(mcpRecord(target, after));
95
+ if (!base) return [];
96
+ if (target === "claude") return HOOK_EVENTS.map(event => ({ event, command: `${base} hook ${event}` }));
97
+ if (target === "pi") return [];
98
+ return [{ event: "SessionEnd", command: `${base} ingest ${target}${target === "codex" || target === "kimi" || target === "qwen" ? " --json" : ""}` }];
99
+ }
100
+
101
+ /** Codex stores trust in config.toml under hooks.state, keyed by hooks.json path and handler position. */
102
+ function codexMissingTrust(hooksPath: string, hooksImage: ConfigImage, configImage: ConfigImage, expected: { event: string; command: string }[]): string[] {
103
+ if (hooksImage.bytes === null || configImage.bytes === null) return [];
104
+ const hooksDocument: unknown = JSON.parse(Buffer.from(hooksImage.bytes, "base64").toString("utf8"));
105
+ const configDocument: unknown = Bun.TOML.parse(Buffer.from(configImage.bytes, "base64").toString("utf8"));
106
+ if (!plain(hooksDocument) || !plain(configDocument)) throw Error("invalid Codex configuration");
107
+ const groups: unknown[] = plain(hooksDocument.hooks) && Array.isArray(hooksDocument.hooks.SessionEnd) ? hooksDocument.hooks.SessionEnd : [];
108
+ const stateRoot: Record<string, unknown> = plain(configDocument.hooks) && plain(configDocument.hooks.state) ? configDocument.hooks.state : Object.create(null);
109
+ const wanted = new Set(expected.filter(row => row.event === "SessionEnd").map(row => row.command));
110
+ const missing: string[] = [];
111
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
112
+ const group = groups[groupIndex]; if (!plain(group) || !Array.isArray(group.hooks)) continue;
113
+ for (let handlerIndex = 0; handlerIndex < group.hooks.length; handlerIndex++) {
114
+ const handler = group.hooks[handlerIndex];
115
+ if (!plain(handler) || typeof handler.command !== "string" || !wanted.has(handler.command)) continue;
116
+ const key = `${hooksPath}:session_end:${groupIndex}:${handlerIndex}`;
117
+ const state = stateRoot[key];
118
+ // Presence is not proof that the stored digest still matches; amem only detects a missing record.
119
+ if (!plain(state) || typeof state.trusted_hash !== "string" || !state.trusted_hash || state.enabled === false) missing.push(key);
120
+ }
121
+ }
122
+ return missing;
123
+ }
124
+
125
+ function entry(target: SetupTarget, file: string, name: string, reason: string, kind: SetupDriftEntry["kind"]): SetupDriftEntry {
126
+ return { target, file, entry: name, reason, kind };
127
+ }
128
+
129
+ /** Read setup receipts and only the resolver-selected native config files. This function never writes. */
130
+ export function readSetupDrift(options: { home: string; env?: SetupEnvironment; cwd?: string }): SetupDriftReport {
131
+ const env = options.env ?? process.env;
132
+ const root = workspaceRoot(options.cwd ?? process.cwd());
133
+ let resolved: ReturnType<typeof resolveSetupPaths>;
134
+ try { resolved = resolveSetupPaths(env, undefined, root); }
135
+ catch { return { status: "unavailable", checked: 0, entries: [], reason: "setup paths could not be resolved" }; }
136
+ const entries: SetupDriftEntry[] = [], budget = { remaining: READ_BYTES };
137
+ let checked = 0, unavailable = false, anyReceipt = false;
138
+ for (const target of Object.keys(resolved.paths) as SetupTarget[]) {
139
+ const expectedPaths = resolved.paths[target];
140
+ if (!expectedPaths.length) continue;
141
+ const receiptPath = join(options.home, "setup", target === "agy" ? `agy-${hash(root)}.json` : `${target}.json`);
142
+ let raw: ConfigImage;
143
+ try { raw = safeImage(receiptPath, JOURNAL_BYTES, budget); }
144
+ catch {
145
+ entries.push(entry(target, receiptPath, "receipt", "receipt cannot be read safely or exceeds the read bound", "unavailable")); unavailable = true;
146
+ continue;
147
+ }
148
+ if (raw.bytes === null) continue;
149
+ let receipt: Receipt;
150
+ try {
151
+ const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(raw.bytes, "base64")));
152
+ if (plain(value) && value.target === target && value.phase === "prepared") continue;
153
+ anyReceipt = true; checked++;
154
+ receipt = parseReceipt(value, target, expectedPaths);
155
+ } catch {
156
+ entries.push(entry(target, receiptPath, "receipt", "receipt is invalid or unavailable", "unavailable")); unavailable = true; continue;
157
+ }
158
+ // Never open a manifest-selected file before the full set of paths matches the shared resolver.
159
+ const mismatches = receipt.changes.filter((change, index) => change.path !== expectedPaths[index]);
160
+ if (mismatches.length) {
161
+ for (const change of mismatches) entries.push({ ...entry(target, change.path, change.key,
162
+ "manifest path no longer matches the resolved target; referenced files were not opened", "drift"),
163
+ expectedFile: expectedPaths[receipt.changes.indexOf(change)] });
164
+ continue;
165
+ }
166
+ const liveImages: (ConfigImage | undefined)[] = [];
167
+ for (const change of receipt.changes) {
168
+ let current: ConfigImage;
169
+ try { current = safeImage(change.path, SETUP_CONFIG_BYTES, budget); }
170
+ catch {
171
+ liveImages.push(undefined);
172
+ entries.push(entry(target, change.path, change.key, "resolved configuration cannot be read safely", "unavailable")); unavailable = true; continue;
173
+ }
174
+ liveImages.push(current);
175
+ const isMcp = change.key === `${target}:0`;
176
+ if (isMcp) {
177
+ const name = target === "codex" ? "mcp_servers.amem" : target === "jcode" ? "servers.amem" : target === "opencode" ? "mcp.amem" : "mcpServers.amem";
178
+ try {
179
+ const expected = mcpRecord(target, change.after);
180
+ if (expected === undefined) throw Error("receipt has no MCP registration");
181
+ if (!isDeepStrictEqual(expected, mcpRecord(target, current))) entries.push(entry(target, change.path, name, "managed MCP registration is missing or changed", "drift"));
182
+ } catch { entries.push(entry(target, change.path, name, "managed MCP registration could not be checked", "unavailable")); unavailable = true; }
183
+ }
184
+ const hasHookSurface = target === "qwen" && change.key === `${target}:0` || target === "claude" && change.key === `${target}:1` || target === "codex" && change.key === `${target}:1` || target === "kimi" && change.key === `${target}:1`;
185
+ if (hasHookSurface) {
186
+ try {
187
+ const expected = expectedManagedHookCommands(target, receipt.changes[0]!.after);
188
+ for (const row of managedHookEntries(target, change.before, change.after, current, expected)) {
189
+ if (!row.present) entries.push(entry(target, change.path, row.entry, "managed hook is missing or changed", "drift"));
190
+ }
191
+ } catch { entries.push(entry(target, change.path, "hooks", "managed hooks could not be checked", "unavailable")); unavailable = true; }
192
+ }
193
+ if (target === "pi" && change.key === "pi:1" && !managedAfter(target, 1, change.before, change.after, current)) entries.push(entry(target, change.path, "session_shutdown extension", "managed hook extension is missing or changed", "drift"));
194
+ // #203: jcode's capture hooks are command elements in config.toml [hooks]; a pre-#203 receipt has no such change.
195
+ if (target === "jcode" && change.key === "jcode:1" && !managedAfter(target, 1, change.before, change.after, current)) entries.push(entry(target, change.path, "hooks", "managed hook is missing or changed", "drift"));
196
+ if (target === "codex" && change.key === "codex:1") {
197
+ // Codex owns trust state. Doctor checks only whether an entry exists; it never writes or verifies trusted_hash.
198
+ const runtimePaths = resolved.redirects.some(row => row.target === "codex") ? resolved.legacyPaths.codex : expectedPaths;
199
+ const configPath = runtimePaths[0]!, hooksPath = runtimePaths[1]!;
200
+ try {
201
+ const configImage = configPath === receipt.changes[0]!.path ? liveImages[0] : safeImage(configPath, SETUP_CONFIG_BYTES, budget);
202
+ if (!configImage) throw Error("Codex config is unavailable");
203
+ const hookImage = hooksPath === change.path ? current : safeImage(hooksPath, SETUP_CONFIG_BYTES, budget);
204
+ const expected = expectedManagedHookCommands(target, receipt.changes[0]!.after);
205
+ if (hooksPath !== change.path && (hookImage.bytes === null || configImage.bytes === null || managedHookEntries(target, change.before, change.after, hookImage, expected).some(row => !row.present))) {
206
+ entries.push(entry(target, hooksPath, "hooks.SessionEnd", "personal hook source is waiting for the managed runtime copy; start a new Orca session, then review the hook", "review-needed"));
207
+ } else {
208
+ for (const key of codexMissingTrust(hooksPath, hookImage, configImage, expected)) entries.push(entry(target, configPath, `hooks.state.${key}`, "Codex hook has no enabled trust record; treat it as untrusted and review it in Codex", "review-needed"));
209
+ }
210
+ } catch { entries.push(entry(target, configPath, "hooks.state", "Codex hook trust could not be checked safely", "unavailable")); unavailable = true; }
211
+ }
212
+ }
213
+ }
214
+ const status: SetupDriftStatus = entries.some(value => value.kind === "drift" || value.kind === "review-needed") ? "attention"
215
+ : unavailable ? "unavailable" : anyReceipt ? "ok" : "skipped";
216
+ return { status, checked, entries, ...(!anyReceipt && !entries.length ? { reason: "no applied setup receipts found" } : {}) };
217
+ }
@@ -1,7 +1,72 @@
1
1
  import type { ConfigImage } from "./setup-owned.ts";
2
2
  import { createJsonMcpCodec } from "./setup-json.ts";
3
+ import { setTomlPath } from "./setup-toml.ts";
3
4
 
4
5
  const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
5
6
  export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
6
7
  export function putJcodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
7
8
  export function restoreJcodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
9
+
10
+ // #203: jcode v0.99.1 `[hooks]` in <JCODE_HOME>/config.toml (crates/jcode-config-types HooksConfig). Each event accepts a
11
+ // command string or an ordered array of strings; observers in an array all run. amem owns only its own command element.
12
+ export const JCODE_CAPTURE_EVENTS = ["post_tool", "turn_end"] as const;
13
+ const tomlText = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
14
+ const tomlImage = (value: string, mode: number): ConfigImage => ({ bytes: Buffer.from(value).toString("base64"), mode });
15
+ function hooksTable(text: string): Record<string, unknown> | undefined {
16
+ let document: unknown;
17
+ try { document = Bun.TOML.parse(text); } catch { throw Error("Jcode config.toml does not parse as TOML"); }
18
+ const hooks = (document as Record<string, unknown>).hooks;
19
+ if (hooks === undefined) return undefined;
20
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) throw Error("Jcode [hooks] must be a table");
21
+ return hooks as Record<string, unknown>;
22
+ }
23
+ function commands(value: unknown): string[] {
24
+ if (value === undefined) return [];
25
+ if (typeof value === "string") return value.trim() ? [value] : [];
26
+ if (Array.isArray(value) && value.every(item => typeof item === "string")) return value as string[];
27
+ throw Error("Jcode hook commands must be a string or an array of strings");
28
+ }
29
+ /** Adds `command` to each capture event, keeping any existing user command ahead of it (array form). */
30
+ export function putJcodeHooks(before: ConfigImage, command: string): ConfigImage {
31
+ let text = tomlText(before);
32
+ const hooks = hooksTable(text) ?? {};
33
+ for (const event of JCODE_CAPTURE_EVENTS) {
34
+ const existing = commands(hooks[event]);
35
+ if (existing.includes(command)) continue;
36
+ text = setTomlPath(text, ["hooks", event], existing.length ? [...existing, command] : command);
37
+ }
38
+ return tomlImage(text, before.mode);
39
+ }
40
+ /** amem's command elements, per event, that `after` added over `before` (the receipt proves ownership, not the runtime). */
41
+ function ownedCommands(before: ConfigImage, after: ConfigImage): Map<string, string[]> {
42
+ const b = hooksTable(tomlText(before)) ?? {}, a = hooksTable(tomlText(after)) ?? {}, owned = new Map<string, string[]>();
43
+ for (const event of JCODE_CAPTURE_EVENTS) { const prior = commands(b[event]); owned.set(event, commands(a[event]).filter(item => !prior.includes(item))); }
44
+ return owned;
45
+ }
46
+ export function jcodeHooksManaged(before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
47
+ try { const hooks = hooksTable(tomlText(current)) ?? {}; return [...ownedCommands(before, after)].every(([event, owned]) => owned.every(item => commands(hooks[event]).includes(item))); }
48
+ catch { return false; }
49
+ }
50
+ /** Exact restore when untouched; otherwise removes only amem's command elements, keeping user commands and later edits. */
51
+ export function restoreJcodeHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
52
+ if (current.bytes === after.bytes) return { ...before, mode: current.mode };
53
+ if (current.bytes === before.bytes) return current;
54
+ const original = hooksTable(tomlText(before)) ?? {}, owned = ownedCommands(before, after);
55
+ let text = tomlText(current);
56
+ const hooks = hooksTable(text) ?? {};
57
+ for (const event of JCODE_CAPTURE_EVENTS) {
58
+ const live = commands(hooks[event]), mine = owned.get(event) ?? [];
59
+ if (!live.some(item => mine.includes(item))) continue;
60
+ const kept = live.filter(item => !mine.includes(item)), prior = original[event];
61
+ // Return the user's original spelling when only amem's element was added; otherwise keep the remaining array.
62
+ const value = !kept.length ? (prior !== undefined && !commands(prior).length ? prior : undefined)
63
+ : kept.length === 1 && typeof prior === "string" && prior === kept[0] ? prior : kept;
64
+ text = setTomlPath(text, ["hooks", event], value);
65
+ }
66
+ if (hooksTable(tomlText(before)) === undefined) {
67
+ const left = hooksTable(text);
68
+ if (left !== undefined && !Object.keys(left).length) text = setTomlPath(text, ["hooks"], undefined);
69
+ }
70
+ if (before.bytes === null && !Object.keys(Bun.TOML.parse(text) as object).length) return { ...before, mode: current.mode };
71
+ return tomlImage(text, current.mode);
72
+ }
@@ -1,6 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
3
- import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp } from "./setup-jcode.ts";
3
+ import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp, jcodeHooksManaged, restoreJcodeHooks } from "./setup-jcode.ts";
4
4
  import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
5
5
  import { readOpencodeMcp, putOpencodeMcp, restoreOpencodeMcp } from "./setup-opencode.ts";
6
6
  export interface ConfigImage { bytes: string | null; mode: number }
@@ -119,6 +119,7 @@ export function restoreOwned(target: string, index: number, before: ConfigImage,
119
119
  if (target === "agy" && index === 0) return restoreAgyMcp(before, after, current);
120
120
  if (target === "opencode" && index === 0) return restoreOpencodeMcp(before, after, current);
121
121
  if (target === "jcode" && index === 0) return restoreJcodeMcp(before, after, current);
122
+ if (target === "jcode" && index === 1) return restoreJcodeHooks(before, after, current);
122
123
  if (sameImage(current, after)) return before;
123
124
  if (sameImage(current, before)) return current;
124
125
  // Qwen keeps both managed surfaces in one settings file and one receipt image.
@@ -136,6 +137,7 @@ export function managedAfter(target: string, index: number, before: ConfigImage,
136
137
  return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id));
137
138
  }
138
139
  if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
140
+ if (target === "jcode") return jcodeHooksManaged(before, after, current);
139
141
  if (target === "claude" || target === "codex") {
140
142
  const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));
141
143
  return needed.every(row => live.some(value => value.id === row.id));
@@ -143,3 +145,18 @@ export function managedAfter(target: string, index: number, before: ConfigImage,
143
145
  if (target === "kimi") return added(kimiHooks(before), kimiHooks(after), kimiKey).every(row => kimiHooks(current).some(value => kimiKey(value) === kimiKey(row)));
144
146
  return current.bytes === after.bytes;
145
147
  }
148
+
149
+ /** Read-only receipt diagnostics; use the same hook identity rules as semantic undo. */
150
+ export function managedHookEntries(target: string, before: ConfigImage, after: ConfigImage, current: ConfigImage,
151
+ expected: readonly { event: string; command: string }[]): { entry: string; present: boolean }[] {
152
+ if (target === "kimi") {
153
+ const original = kimiHooks(before), installed = kimiHooks(after), live = kimiHooks(current);
154
+ const ours = new Set(added(original, installed, kimiKey).map(kimiKey));
155
+ return installed.filter(row => ours.has(kimiKey(row)) || expected.some(value => value.event === row.event && value.command === row.command))
156
+ .map(row => ({ entry: `hooks.${String(row.event)}`, present: live.some(value => kimiKey(value) === kimiKey(row)) }));
157
+ }
158
+ const strict = target === "qwen", original = hooks(json(before), strict), installed = hooks(json(after), strict), live = hooks(json(current), strict);
159
+ const ours = new Set(added(original, installed, row => row.id).map(row => row.id));
160
+ return installed.filter(row => ours.has(row.id) || expected.some(value => value.event === row.event && value.command === row.command))
161
+ .map(row => ({ entry: `hooks.${row.event}`, present: live.some(value => value.id === row.id) }));
162
+ }