@staix/agent-mem 0.19.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.
package/src/hooks.ts CHANGED
@@ -15,7 +15,7 @@ import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memor
15
15
  import { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, isHookTranscriptProgress, type HookTranscriptProgress } from "./hook-contract.ts";
16
16
  export { HOOK_EVENTS, HOOK_INPUT_BYTES, HOOK_CONTEXT_BYTES, HOOK_OUTPUT_BYTES, type HookEvent } from "./hook-contract.ts";
17
17
  // Leave 10ms of the external 150ms R2 budget for teardown and the metrics write.
18
- const HANDLER_MS = 140;
18
+ export const HANDLER_MS = 140;
19
19
  // A timed-out child must be reaped and metrics flushed inside the unchanged handler/R2 budgets.
20
20
  // ponytail: reserve measured native teardown variance; an asynchronous hard-deadline transport
21
21
  // through storage/cleanup is the upgrade, not a longer external deadline.
@@ -68,14 +68,17 @@ async function before<T>(work: Promise<T>, deadline: number): Promise<T | undefi
68
68
  }
69
69
  const publicBefore = async (policy: MemoryPolicy, value: unknown, deadline: number) => await before(policy.screen(value), deadline) === true;
70
70
 
71
- async function boundedOutput(stream: ReadableStream<Uint8Array>, onRss?: (value: unknown) => void, onTranscript?: (value: unknown) => void): Promise<string | undefined> {
71
+ async function boundedOutput(stream: ReadableStream<Uint8Array>, onRss?: (value: unknown) => void, onTranscript?: (value: unknown) => void, register?: (cancel: () => void) => void, onDrain?: (value: unknown) => void): Promise<string | undefined> {
72
72
  const reader = stream.getReader(), decoder = new TextDecoder("utf-8", { fatal: true });
73
+ // #226: after a bounded reap the parent abandons this pipe; cancelling it leaves no pending read holding the event loop.
74
+ register?.(() => { reader.cancel().catch(() => {}); });
73
75
  let length = 0, pending = "", body = "";
74
76
  const line = (text: string) => {
75
77
  if (!text) return;
76
- const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown };
78
+ const value = JSON.parse(text) as { rss?: unknown; transcript?: unknown; drain?: unknown };
77
79
  if (onRss && Object.hasOwn(value, "rss")) onRss(value.rss);
78
80
  else if (onTranscript && Object.hasOwn(value, "transcript")) onTranscript(value.transcript);
81
+ else if (onDrain && Object.hasOwn(value, "drain")) onDrain(value.drain);
79
82
  else { if (body) throw new Error("worker output bound"); body = text; }
80
83
  };
81
84
  try {
@@ -106,73 +109,120 @@ async function parentProject(options: HookOptions, base: string, deadline: numbe
106
109
  return { cwd, scope, project, receipt, usage };
107
110
  }
108
111
 
112
+ /** #226: after SIGKILL the parent waits at most this long for the owned worker's exit and output. A worker stuck in
113
+ * uninterruptible I/O (fsync) can outlive the hook; the kill is already sent, its pipe is cancelled and its handle unref'd,
114
+ * so nothing keeps this process alive and nothing stays resident. R1 judges it after a fixed 2 s post-exit window. */
115
+ const REAP_BOUND_MS = 10;
116
+ async function boundedWait<T>(work: Promise<T>): Promise<{ value?: T; late: boolean }> {
117
+ let timer: ReturnType<typeof setTimeout> | undefined, late = false;
118
+ const value = await Promise.race([work, new Promise<undefined>(resolve => { timer = setTimeout(() => { late = true; resolve(undefined); }, REAP_BOUND_MS); })]);
119
+ if (timer) clearTimeout(timer);
120
+ return late ? { late } : { value: value as T, late };
121
+ }
122
+ /** Owned children killed but still alive when the hook returned; recorded in the hook metric. */
123
+ let abandonedChildren = 0;
124
+ /** Transcript hooks whose worker was killed before its drain finished; their staged records stay durable and pending. */
125
+ let drainSkipped = 0;
126
+ /** #226 instrumentation: the last worker drain's start (ms after hook start) and committed record count. */
127
+ let lastDrain: { startMs: number; drained: number } | undefined;
128
+ /** Hook start on the performance clock, set by the entry points, so a worker can report times relative to it. */
129
+ let hookStartedAt = performance.now();
130
+
109
131
  /** Only capture work uses a short-lived owned child; native handles remain in this CLI.
132
+ * #226 (ruling b): a transcript hook boots that one worker at hook start, in parallel with native metadata. The worker
133
+ * scans as soon as its first stdin line (scan options) arrives and drains after the second (the native receipt). At the
134
+ * deadline the parent kills it and waits at most REAP_BOUND_MS; a kill skips that call's drain (counted, never a drop).
110
135
  * Host callbacks cannot cross this boundary; a host needing them calls sessionStartBrief in-process instead. */
111
136
  async function hookWorker(options: HookOptions, base: string, deadline: number, pending: PendingHookCapture, event: string, transcript?: unknown): Promise<{ context: string; rssKB: number; rawRssKB: number; captured: boolean; valid: boolean; rssSources?: HookRssSources; transcript?: HookTranscriptProgress }> {
112
137
  const workDeadline = deadline - WORKER_CLEANUP_MS;
113
138
  if (options.policy?.host || workDeadline - performance.now() < 5) return { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false };
114
- const metadata = await parentProject(options, base, workDeadline);
115
- if (!metadata.receipt && transcript === undefined || workDeadline - performance.now() < 5) return { context: unavailable, ...metadata.usage, captured: false, valid: false, ...(transcript !== undefined ? { transcript: { stopReason: "deadline" as const } } : {}) };
116
- const result = { context: unavailable, ...metadata.usage, captured: false, valid: false,
117
- rssSources: combineRssSources(metadata.usage.rssSources), transcript: undefined as HookTranscriptProgress | undefined };
118
- let owned: ReturnType<typeof Bun.spawn> | undefined, exited = false;
119
- try {
120
- const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
121
- const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
122
- // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
123
- const payload = JSON.stringify({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, transcript, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline,
124
- policy: { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns } });
139
+ const early = transcript !== undefined;
140
+ const policy = { denyPaths: options.policy?.denyPaths, piiPatterns: options.policy?.piiPatterns };
141
+ let owned: Bun.Subprocess<"pipe", "pipe", "ignore"> | undefined, exited = false, killed = false, cancelOutput: (() => void) | undefined;
142
+ let tracker: HookWorkerRss | undefined, output: Promise<string | undefined> | undefined, exit: Promise<number> | undefined;
143
+ const result = { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false, rssSources: emptyRssSources(), transcript: undefined as HookTranscriptProgress | undefined };
144
+ const drainProgress: { startMs?: number; drained?: number } = {};
145
+ const spawn = () => {
125
146
  const child = Bun.spawn([process.execPath, "--smol", fileURLToPath(new URL("./session-brief-worker.ts", import.meta.url))],
126
- { cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: Buffer.from(payload), stdout: "pipe", stderr: "ignore" });
147
+ { cwd: options.cwd, env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", AMEM_HOME: base }, detached: true, stdin: "pipe", stdout: "pipe", stderr: "ignore" });
127
148
  owned = child;
128
- result.rssSources.unavailable++;
129
- const exit = child.exited.then(code => { exited = true; return code; });
130
- const tracker = new HookWorkerRss(child.pid);
131
- const output = boundedOutput(child.stdout, value => tracker.frame(value), value => {
149
+ exit = child.exited.then(code => { exited = true; return code; });
150
+ tracker = new HookWorkerRss(child.pid);
151
+ output = boundedOutput(child.stdout, value => tracker!.frame(value), value => {
132
152
  if (!isHookTranscriptProgress(value)) throw new Error("worker transcript progress bound");
133
153
  if (value.offset !== undefined && result.transcript?.offset !== undefined && value.offset < result.transcript.offset) throw new Error("worker transcript progress regressed");
134
- result.transcript = { ...result.transcript, ...value };
154
+ // A reported scan fault stays reported: a later worker deadline frame cannot turn it into accepted progress (#221).
155
+ result.transcript = { ...result.transcript, ...value, ...(result.transcript?.stopReason === "unavailable" ? { stopReason: "unavailable" as const } : {}) };
156
+ }, cancel => { cancelOutput = cancel; }, value => {
157
+ // #226 instrumentation: bounded numeric drain progress; anything else is ignored, never trusted.
158
+ const row = value as { startMs?: unknown; drained?: unknown } | null;
159
+ if (row && typeof row.startMs === "number" && Number.isFinite(row.startMs) && row.startMs >= 0 && row.startMs < 10_000) drainProgress.startMs = row.startMs;
160
+ if (row && Number.isSafeInteger(row.drained) && (row.drained as number) >= 0 && (row.drained as number) <= 1024) drainProgress.drained = row.drained as number;
135
161
  });
136
- const stop = () => {
137
- if (exited) return;
138
- tracker.captureLive();
139
- // The still-live leader is the detached process we just spawned, so its group is ours.
140
- // The capture-only worker owns no native helper; those handles were reaped in this parent.
141
- try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* Already exited. */ } }
142
- };
143
- let timer: ReturnType<typeof setTimeout> | undefined;
144
- try {
145
- const answer = await Promise.race([
146
- Promise.all([exit, output]),
147
- new Promise<undefined>(resolve => { timer = setTimeout(() => { if (transcript !== undefined) result.transcript ??= { stopReason: "deadline" }; stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
148
- ]);
149
- if (!answer || !answer[1]) return result;
150
- const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown; rssTelemetryValid?: unknown };
151
- if (parsed.complete !== true) return result;
152
- if (parsed.rssTelemetryValid !== true) tracker.invalidate();
153
- if (typeof parsed.rssKB === "number" && Number.isSafeInteger(parsed.rssKB) && parsed.rssKB > 0) result.rssKB = parsed.rssKB;
154
- if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
155
- result.captured = parsed.captured === true; result.valid = parsed.valid === true;
156
- return result;
157
- } catch { return result; }
158
- finally {
159
- if (timer) clearTimeout(timer);
160
- stop();
161
- await exit;
162
- // Bun subprocess usage is bytes; process.resourceUsage below is KiB.
163
- const workerRawKB = (child.resourceUsage()?.maxRSS ?? 0) / 1024;
164
- result.rawRssKB = metadata.usage.rawRssKB + workerRawKB;
165
- if (await output === undefined) tracker.invalidate();
166
- const accounting = tracker.finish(workerRawKB);
167
- result.rssKB = metadata.usage.rssKB + accounting.rssKB; result.rssSources = combineRssSources(metadata.usage.rssSources, accounting.rssSources);
162
+ return child;
163
+ };
164
+ const send = (line: object) => { try { owned!.stdin.write(JSON.stringify(line) + "\n"); owned!.stdin.flush(); } catch { /* A dead worker reports nothing. */ } };
165
+ const close = () => { try { owned?.stdin.end(); } catch { /* Already closed. */ } };
166
+ const stop = () => {
167
+ if (!owned || exited) return;
168
+ tracker!.captureLive();
169
+ killed = true;
170
+ // The still-live leader is the detached process we just spawned, so its group is ours.
171
+ // The capture-only worker owns no native helper; those handles were reaped in this parent.
172
+ try { process.kill(-owned.pid, "SIGKILL"); } catch { try { owned.kill("SIGKILL"); } catch { /* Already exited. */ } }
173
+ };
174
+ let timer: ReturnType<typeof setTimeout> | undefined, metadata: Awaited<ReturnType<typeof parentProject>> | undefined;
175
+ try {
176
+ if (early) {
177
+ spawn();
178
+ send({ ownedGroup: true, scan: { cwd: options.cwd, base, transcript, policy, deadlineAt: performance.timeOrigin + workDeadline } });
168
179
  }
180
+ const configuredBrief = loadAmemConfig(base, repoConfigRoot(options.cwd)).config.briefBytes;
181
+ const maxTokens = Math.min(HOOK_CONTEXT_BYTES, configuredBrief ?? HOOK_CONTEXT_BYTES, Math.max(1, options.maxTokens ?? HOOK_CONTEXT_BYTES));
182
+ metadata = await parentProject(options, base, workDeadline);
183
+ result.rssKB = metadata.usage.rssKB; result.rawRssKB = metadata.usage.rawRssKB; result.rssSources = combineRssSources(metadata.usage.rssSources);
184
+ if (!early && (!metadata.receipt || workDeadline - performance.now() < 5)) return result;
185
+ if (!owned) spawn();
186
+ result.rssSources.unavailable++;
187
+ // The worker reads the configuration itself (same base, same cwd), so it screens with exactly the policy the parent did.
188
+ // Without a receipt the worker still finishes the scan and reports it, but drains nothing.
189
+ send({ cwd: options.cwd, base, current: pending.name, event, identity: metadata.receipt, maxTokens, ownedGroup: true, deadlineAt: performance.timeOrigin + workDeadline, hookStartAt: performance.timeOrigin + hookStartedAt, policy });
190
+ close();
191
+ const answer = await Promise.race([
192
+ Promise.all([exit!, output!]),
193
+ new Promise<undefined>(resolve => { timer = setTimeout(() => { stop(); resolve(undefined); }, Math.max(1, workDeadline - performance.now())); }),
194
+ ]);
195
+ if (!answer || !answer[1]) return result;
196
+ const parsed = JSON.parse(answer[1]) as { context?: unknown; captured?: unknown; valid?: unknown; complete?: unknown; rssKB?: unknown; rssTelemetryValid?: unknown };
197
+ if (parsed.complete !== true) return result;
198
+ if (parsed.rssTelemetryValid !== true) tracker!.invalidate();
199
+ if (typeof parsed.context === "string" && Buffer.byteLength(parsed.context) <= maxTokens) result.context = parsed.context;
200
+ result.captured = parsed.captured === true; result.valid = parsed.valid === true;
201
+ return result;
169
202
  } catch { result.valid = false; return result; }
170
203
  finally {
171
- // Even setup or accounting failure after spawn cannot leave an owned image live.
204
+ if (timer) clearTimeout(timer);
205
+ close();
172
206
  if (owned) {
173
- if (!exited) try { process.kill(-owned.pid, "SIGKILL"); } catch { try { owned.kill("SIGKILL"); } catch { /* Already gone. */ } }
174
- await owned.exited;
175
- }
207
+ stop();
208
+ // #226: bounded reap; a worker stuck in uninterruptible I/O is abandoned after REAP_BOUND_MS, never awaited.
209
+ const reaped = exited || !(await boundedWait(exit!)).late;
210
+ const body = reaped ? await boundedWait(output!) : { late: true };
211
+ if (!reaped || body.late) { cancelOutput?.(); owned.unref(); }
212
+ if (!reaped) abandonedChildren++;
213
+ // Bun subprocess usage is bytes; process.resourceUsage is KiB. Not reaped: the last live sample is the RSS (ruling Q2).
214
+ const workerRawKB = reaped ? (owned.resourceUsage()?.maxRSS ?? 0) / 1024 : 0;
215
+ if (reaped ? body.late || body.value === undefined : !tracker!.hasLiveSample()) tracker!.invalidate();
216
+ const accounting = tracker!.finish(workerRawKB);
217
+ const usage = metadata?.usage ?? { rssKB: 0, rawRssKB: 0, rssSources: emptyRssSources() };
218
+ result.rssKB = usage.rssKB + accounting.rssKB; result.rawRssKB = usage.rawRssKB + workerRawKB; result.rssSources = combineRssSources(usage.rssSources, accounting.rssSources);
219
+ if (drainProgress.startMs !== undefined) lastDrain = { startMs: drainProgress.startMs, drained: drainProgress.drained ?? 0 };
220
+ if (early && killed) {
221
+ // #221: a kill without accepted progress is a deadline stop; a kill also skips this call's drain (ruling b).
222
+ if (result.transcript?.offset === undefined && result.transcript?.stopReason !== "unavailable") result.transcript = { stopReason: "deadline" };
223
+ if (!result.valid) drainSkipped++;
224
+ }
225
+ } else if (early) result.transcript ??= { stopReason: "deadline" };
176
226
  }
177
227
  }
178
228
 
@@ -245,7 +295,12 @@ async function sessionInParent(input: unknown, options: HookOptions, base: strin
245
295
  async function captureAndWork(event: string, input: unknown, options: HookOptions, base: string, deadline: number) {
246
296
  if (event === "SessionStart") return sessionInParent(input, options, base, deadline);
247
297
  const transcript = (event === "Stop" || event === "SessionEnd") && input !== null && typeof input === "object" && "transcript_path" in input && typeof input.transcript_path === "string" ? input : undefined;
248
- const work = (pending: PendingHookCapture) => hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event, transcript);
298
+ // #221: a transcript-bearing hook that ends without worker progress reports `deadline` only on positive evidence:
299
+ // the remaining time, measured at that exit, is below the worker's own start threshold. Policy, floor or invalid-input
300
+ // exits keep reporting nothing, so they cannot pass for bounded progress.
301
+ const deadlineEvidence = () => transcript !== undefined && deadline - WORKER_CLEANUP_MS - performance.now() < 5;
302
+ const withDeadline = <T extends { transcript?: HookTranscriptProgress }>(value: T): T => value.transcript === undefined && deadlineEvidence() ? { ...value, transcript: { stopReason: "deadline" } } : value;
303
+ const work = async (pending: PendingHookCapture) => withDeadline(await hookWorker({ ...options, cwd: pending.cwd }, base, deadline, pending, event, transcript));
249
304
  let result: Awaited<ReturnType<typeof work>> | undefined;
250
305
  let pending: PendingHookCapture | undefined;
251
306
  try { pending = await stageHookCapture(event, input, { ...options, base, deferAtCapacity: true }); }
@@ -261,7 +316,11 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
261
316
  return { ...result, pending: false, dropped: true };
262
317
  }
263
318
  }
264
- if (!pending) return result ? { ...result, pending: false } : undefined;
319
+ if (!pending) {
320
+ if (result) return { ...result, pending: false };
321
+ // Staging declined (policy, floor, invalid input) or ran out of time: only measured time makes it a deadline stop.
322
+ return deadlineEvidence() ? { context: unavailable, rssKB: 0, rawRssKB: 0, captured: false, valid: false, pending: false, transcript: { stopReason: "deadline" as const } } : undefined;
323
+ }
265
324
  result ??= await work(pending);
266
325
  return { ...result, pending: !result.captured };
267
326
  }
@@ -270,6 +329,7 @@ async function captureAndWork(event: string, input: unknown, options: HookOption
270
329
  export async function runHook(event: string, input: unknown, options: HookOptions, host?: HookHostPorts): Promise<HookReceipt> {
271
330
  const now = performance.now(), started = options.startedAt !== undefined && Number.isFinite(options.startedAt) && options.startedAt <= now ? options.startedAt : now;
272
331
  const deadline = started + handlerMs(host);
332
+ hookStartedAt = started;
273
333
  let base: string | undefined, captured = false, childRssKB = 0, childRawRssKB = 0, pending = false, dropped = false;
274
334
  let childSources = emptyRssSources();
275
335
  let transcriptProgress: HookTranscriptProgress | undefined;
@@ -291,13 +351,14 @@ export async function runHook(event: string, input: unknown, options: HookOption
291
351
  finally {
292
352
  if (base) try { const self = hookRssReading(); appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - started,
293
353
  rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
294
- ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
354
+ ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]); } catch { /* Metrics never change the native tool outcome. */ }
295
355
  }
296
356
  }
297
357
 
298
358
  /** Bounded stdin and a fixed overall handler budget; failures produce no native decision and exit successfully. */
299
359
  export async function hookMain(args: readonly string[], startedAt = performance.now(), host?: HookHostPorts): Promise<string> {
300
360
  const deadlineMs = handlerMs(host);
361
+ hookStartedAt = startedAt;
301
362
  let cleanup: (() => Promise<void>) | undefined;
302
363
  let childRssKB = 0, childRawRssKB = 0, dropped = false;
303
364
  let childSources = emptyRssSources();
@@ -341,7 +402,7 @@ export async function hookMain(args: readonly string[], startedAt = performance.
341
402
  const self = hookRssReading();
342
403
  appendMetrics(canonicalPath(memoryHome()), [{ at: new Date().toISOString(), kind: "hook", wallMs: performance.now() - startedAt,
343
404
  rssMB: (self.kb + childRssKB) / 1024, rssSources: combineRssSources(rssSource(self), countedRssSources(childRssKB, childSources)), rssScope: "owned-images", rawRssMB: (process.resourceUsage().maxRSS + childRawRssKB) / 1024,
344
- ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
405
+ ...(transcriptProgress ? { transcriptOffset: transcriptProgress.offset, transcriptStopReason: transcriptProgress.stopReason } : {}), ...(abandonedChildren ? { abandonedChildren } : {}), ...(drainSkipped ? { drainSkipped } : {}), ...(lastDrain ? { drainStartMs: lastDrain.startMs, drained: lastDrain.drained } : {}), ...(dropped ? { captureDrops: 1, captureDropReason: "spool_full" as const } : {}) }]);
345
406
  } catch { /* Invalid input and unavailable homes still fail open. */ }
346
407
  }
347
408
  }
@@ -19,6 +19,7 @@ import { aliasedKeys, shardDir } from "./shard.ts";
19
19
  import type { MemoryStore, View } from "./store.ts";
20
20
  import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
21
21
  import { readSetupDrift } from "../setup-drift.ts";
22
+ import { readHookSpoolStatus } from "../hook-spool.ts";
22
23
 
23
24
  /**
24
25
  * F15, `amem doctor`: one report over the memory home. It writes nothing into the home. Every check that needs a store (the notes, the brief, the index proof) runs on a
@@ -63,6 +64,8 @@ export interface DoctorReport {
63
64
  duplicates: Section; stale: Section; candidates: Section; capture: Section; brief: Section; index: Section; readers: Section; policy: Section; cadence: Section;
64
65
  /** Added after v1 shipped (#132); additive, so the schema stays amem.doctor/v1. */
65
66
  drops: Section;
67
+ /** #217, additive: bounded read-only home-wide hook backlog inventory. */
68
+ spool?: Section;
66
69
  /** #188, additive: present only when the project's curated logs hold forget or prune operations. Informational; status is always ok. */
67
70
  masked?: Section;
68
71
  /** #202, additive: applied native setup registrations checked against the shared path resolver. */
@@ -363,6 +366,22 @@ function dropsSection(options: DoctorOptions): Section {
363
366
  ...(total ? { hint: "capture rows were dropped recently (see counts by reason); amem doctor --json keeps the numbers" } : {}) };
364
367
  }
365
368
 
369
+ /** Bounded read-only inventory and classification; no replay, claim or retirement. */
370
+ function spoolSection(options: DoctorOptions): Section {
371
+ try {
372
+ const inventory = readHookSpoolStatus(options.home);
373
+ const tiers = [inventory.active, inventory.reserve];
374
+ const full = tiers.some(tier => tier.files >= tier.maxFiles || tier.bytes >= tier.maxBytes);
375
+ const near = tiers.some(tier => tier.files >= tier.maxFiles * 0.9 || tier.bytes >= tier.maxBytes * 0.9);
376
+ return { ...inventory, status: !inventory.complete ? "unavailable" : full || near ? "attention" : "ok",
377
+ coverage: inventory.complete ? "complete" : "partial",
378
+ classificationCoverage: inventory.kinds.complete ? "complete" : "partial",
379
+ fill: full ? "full" : near ? "nearly_full" : !inventory.complete ? "unknown" : tiers.some(tier => tier.files > 0) ? "pending" : "empty",
380
+ ...(!inventory.complete ? { reason: "hook spool inventory is partial; reported file and byte counts are lower bounds" } : {}),
381
+ ...(full || near ? { hint: "pending hook spool needs attention: capture can be dropped when both tiers fill; supported orphan recovery is pending" } : {}) };
382
+ } catch { return unavailable("hook spool could not be read safely"); }
383
+ }
384
+
366
385
  /** F16: which CLIs ingest at SessionEnd (setup receipts), and the person-owned sync cadence. amem registers no sync hook (a hook may not do network work). */
367
386
  function cadenceSection(options: DoctorOptions): Section {
368
387
  const ingest: Record<string, string> = {};
@@ -444,7 +463,7 @@ function guardScratch(): { adopt: (dir: string) => void; release: () => void } {
444
463
  export const DOCTOR_GIT_TIMEOUT_MS = 5_000;
445
464
  export function doctorReport(options: DoctorOptions): Promise<DoctorReport> {
446
465
  // Doctor reads every surface below, live or through a scratch copy: a floor on any of them refuses the whole report before anything is read (#199 AC5).
447
- for (const surface of ["curated", "capture", "candidates", "work-lists"] as const) assertFormatFloor(options.home, surface);
466
+ for (const surface of ["curated", "capture", "candidates", "work-lists", "hook-spool"] as const) assertFormatFloor(options.home, surface);
448
467
  return withGitTimeout(DOCTOR_GIT_TIMEOUT_MS, () => runDoctor(options));
449
468
  }
450
469
  async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
@@ -497,7 +516,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
497
516
  let sections: DoctorReport["sections"];
498
517
  try {
499
518
  await tick();
500
- sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), setup, ...maskedSection(curated) };
519
+ sections = { duplicates, stale, candidates, capture: captureSection(options), brief: await briefSection(options, curated, workBase, copyFailed), index, readers: readersSection(options), policy: policySection(options), cadence: cadenceSection(options), drops: dropsSection(options), spool: spoolSection(options), setup, ...maskedSection(curated) };
501
520
  await tick(); // a signal that arrived during the last synchronous stage is handled while the guard is still installed
502
521
  } finally { try { curated?.close(); } finally { try { if (scratch) rmSync(scratch, { recursive: true, force: true }); } finally { unguard?.(); } } }
503
522
  return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
@@ -507,6 +526,7 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
507
526
  const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
508
527
  duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
509
528
  index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence", drops: "recent capture drops",
529
+ spool: "pending hook spool",
510
530
  setup: "native setup registrations",
511
531
  masked: "forgotten or pruned entries (informational)",
512
532
  };
@@ -528,6 +548,16 @@ function sectionDetail(name: string, detail: Record<string, unknown>, s: Style):
528
548
  const counts = Object.entries((detail.counts ?? {}) as Record<string, number>);
529
549
  return [counts.length ? `${counts.map(([reason, n]) => `${reason.replaceAll("_", " ")} ${n}`).join(", ")} (recent metrics tail)` : "none in the recent metrics tail"].map(line => detail.malformedLines ? `${line}; ${detail.malformedLines} malformed lines skipped` : line);
530
550
  }
551
+ if (name === "spool") {
552
+ if (!detail.active || !detail.reserve) return [];
553
+ const tiers = ["active", "reserve"].map(name => {
554
+ const tier = detail[name] as { files: number; bytes: number; maxFiles: number; maxBytes: number };
555
+ return `${name} ${tier.files}/${tier.maxFiles} files, ${tier.bytes}/${tier.maxBytes} bytes`;
556
+ });
557
+ const kinds = detail.kinds as { native: number; summaries: number; unclassified: number; complete: boolean };
558
+ return [`${detail.fill}; ${tiers.join("; ")}; claimed ${detail.claimed}; oldest ${detail.oldestAt ?? "none observed"}; coverage ${detail.coverage}${detail.complete ? "" : " (counts are lower bounds)"}`,
559
+ `native ${kinds.native}, summaries ${kinds.summaries}, unclassified ${kinds.unclassified}; classification ${detail.classificationCoverage}${kinds.complete ? "" : " (native and summary counts are lower bounds)"}`];
560
+ }
531
561
  if (name === "setup" && Array.isArray(detail.entries)) {
532
562
  return (detail.entries as { target?: unknown; file?: unknown; entry?: unknown; reason?: unknown; expectedFile?: unknown }[]).map(item =>
533
563
  singleLine(`${item.target} ${item.file}${item.expectedFile ? ` -> ${item.expectedFile}` : ""} ${item.entry}: ${item.reason}`));
@@ -1,9 +1,10 @@
1
1
  import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import type { HookRssSources } from "../hook-resources.ts";
3
+ import type { HookTranscriptStopReason } from "../hook-contract.ts";
3
4
  import { join } from "node:path";
4
5
 
5
6
  /** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
6
- export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: "cap" | "deadline" | "spool_full"; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
7
+ export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: HookTranscriptStopReason; /** #226: owned children killed but still alive when the hook returned (bounded reap). */ abandonedChildren?: number; /** #226: a killed worker skipped this call's drain; staged records stay pending (not a capture drop). */ drainSkipped?: number; /** #226: when the worker drain started (ms after hook start) and how many records it committed. */ drainStartMs?: number; drained?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
7
8
  const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
8
9
 
9
10
  export function metricsPath(base: string): string {
@@ -37,8 +38,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
37
38
  }
38
39
 
39
40
 
40
- export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
41
- const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
41
+ export type CaptureDropReason = "summary_capacity" | "scope_capacity" | "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
42
+ const DROP_REASONS: readonly CaptureDropReason[] = ["summary_capacity", "scope_capacity", "spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
42
43
  /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
43
44
  export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
44
45
  const read = readCaptureDrops(base);
@@ -49,14 +49,18 @@ export function waitForPublicPruneWriter(home: string, absoluteDeadline?: number
49
49
  const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
50
50
  const intentFile = join(home, PUBLIC_PRUNE_INTENT), lockFile = join(home, "profile.lock");
51
51
  if (!lstatSync(intentFile, { throwIfNoEntry: false }) && !lstatSync(lockFile, { throwIfNoEntry: false })) return;
52
+ // #225: a cooperating writer that finishes unlinks its intent, then its lock. If that lands between this reader's
53
+ // open and fstat, the fd's link count is 0. Only that is the normal disappearance (ENOENT): bytes read through the fd
54
+ // are discarded and the bounded loop re-resolves the path from scratch. Any other link count still refuses.
55
+ const unlinked = (stat: Stats): void => { if (stat.nlink === 0) throw Object.assign(new Error("publication unlinked while open"), { code: "ENOENT" }); };
52
56
  const small = (file: string, limit: number): Record<string, unknown> => {
53
57
  const before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= limit);
54
58
  const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
55
59
  try {
56
- const opened = fstatSync(fd); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
60
+ const opened = fstatSync(fd); unlinked(opened); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
57
61
  const bytes = Buffer.alloc(opened.size + 1); let count = 0;
58
62
  while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
59
- const after = fstatSync(fd); privateStat(after); guard(count === opened.size && same(opened, after));
63
+ const after = fstatSync(fd); unlinked(after); privateStat(after); guard(count === opened.size && same(opened, after));
60
64
  const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count))); guard(object(parsed)); return parsed;
61
65
  } finally { closeSync(fd); }
62
66
  };
@@ -6,35 +6,45 @@ import { hookRssReading, hookPeakRssKB } from "./hook-resources.ts";
6
6
  import { rssSourceCode } from "./hook-rss-wire.ts";
7
7
  import { resolveAlias } from "./memory/aliases.ts";
8
8
  import { HOOK_EVENTS, HOOK_INPUT_BYTES, type HookTranscriptProgress } from "./hook-contract.ts";
9
- import { drainHookCaptures, hookCaptureScope } from "./hook-spool.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
- let rssTelemetryValid = true;
35
+ let owned = false, rssTelemetryValid = true;
36
+ let lastProgress: HookTranscriptProgress | undefined;
29
37
  const frame = (rss: number[]) => { try { writeSync(1, JSON.stringify({ rss }) + "\n"); } catch { rssTelemetryValid = false; } };
30
- const progress = (transcript: HookTranscriptProgress) => { try { writeSync(1, JSON.stringify({ transcript }) + "\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; } };
31
43
  frame([0]);
32
- const stop = (): never => {
44
+ const stop: () => never = () => {
33
45
  if (!rssTelemetryValid) frame([4]);
34
46
  const self = hookRssReading(); frame([3, self.kb, self.rawKB, rssSourceCode(self)]);
35
- // This leader is still alive in the detached group created by hookWorker.
36
- // Native Git is owned and reaped by the CLI parent, never by this worker.
37
- if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* No group exists in a direct, non-owned invocation. */ }
47
+ if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* A direct unowned invocation has no group. */ }
38
48
  process.exit(0);
39
49
  };
40
50
  const emit = async (result: unknown) => {
@@ -43,45 +53,86 @@ if (import.meta.main) {
43
53
  await Bun.stdout.write(body.slice(0, -1) + `,"rssKB":${rssKB}}\n`);
44
54
  stop();
45
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;
46
62
  try {
47
- const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; identity?: unknown; transcript?: unknown; deadlineAt: number; ownedGroup: boolean };
48
- 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();
49
71
  owned = true;
50
- const remaining = () => options.deadlineAt - (performance.timeOrigin + performance.now());
51
- const deadlineStop = () => { if (options.transcript !== undefined) progress({ stopReason: "deadline" }); stop(); };
52
- if (remaining() <= 0) deadlineStop();
53
- setTimeout(deadlineStop, remaining()).unref();
54
- const event = options.event;
55
- if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
56
- const expectedScope = hookCaptureScope(options.cwd, options.base!);
57
- // A receipt is single-use parent IPC. This worker must never launch a native helper.
58
- if (event === "SessionStart") stop();
59
- let project: string | undefined;
60
- try { project = resolveAlias(options.base!, acceptHookProjectReceipt(options.identity, { cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining })); } catch { /* Independently bound transcript intake can still proceed. */ }
61
- if ((event === "Stop" || event === "SessionEnd") && options.transcript !== undefined
62
- && (project !== undefined || expectedScope !== undefined && hookCaptureScope(options.cwd, options.base!) === expectedScope)) {
63
- try {
64
- const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
65
- const scanned = await scanHookTranscriptTail(options.transcript, { cwd: options.cwd, base: options.base!, policy: options.policy,
66
- deadline: Math.min(performance.now() + remaining() - 20, performance.now() + 60) });
67
- if (scanned) progress(scanned);
68
- } catch { /* Ordinary durable capture is independent. */ }
69
- }
70
- if (project === undefined) stop();
71
- let captured = false;
72
- // An empty current name selects bounded full-tier recovery; SessionStart stays in the parent.
73
- if (expectedScope !== undefined) {
74
- const { CaptureStore } = await import("./memory/capture.ts");
75
- let store: CaptureStore | undefined;
76
- try {
77
- const drained = await drainHookCaptures({ ...options, base: options.base!, project: project!, expectedScope, timeRemaining: remaining }, (input, receipt) => {
78
- store ??= new CaptureStore({ memoryDir: options.base!, project: project!, busyTimeoutMs: 0, cwd: options.cwd });
79
- return store.append(input, { receipt });
80
- });
81
- captured = drained.captured.includes(options.current);
82
- } finally { store?.close(); }
83
- }
84
- if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
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();
85
136
  await emit({ context: "", captured, valid: true });
86
137
  } catch { if (owned) stop(); process.exitCode = 1; }
87
138
  }