@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -7
- package/package.json +3 -2
- package/src/cli.ts +82 -6
- package/src/commands-table.ts +8 -1
- package/src/hook-contract.ts +12 -0
- package/src/hook-jcode.ts +56 -0
- package/src/hook-native.ts +66 -13
- package/src/hook-project.ts +27 -4
- package/src/hook-resources.ts +54 -12
- package/src/hook-rss-wire.ts +87 -0
- package/src/hook-spool-replay.ts +332 -0
- package/src/hook-spool.ts +315 -66
- package/src/hook-transcript.ts +205 -0
- package/src/hooks.ts +183 -51
- package/src/memory/candidates.ts +2 -0
- package/src/memory/capture.ts +46 -0
- package/src/memory/catalog.ts +2 -1
- package/src/memory/doctor.ts +54 -5
- package/src/memory/formats.ts +65 -0
- package/src/memory/index-file.ts +15 -0
- package/src/memory/metrics.ts +5 -3
- package/src/memory/public-prune-activation.ts +2 -1
- package/src/memory/public-prune-view.ts +6 -2
- package/src/memory/search-score.ts +3 -2
- package/src/memory/shard.ts +2 -1
- package/src/memory/store.ts +6 -4
- package/src/memory/sync.ts +2 -0
- package/src/memory/work-lists.ts +2 -0
- package/src/session-brief-worker.ts +117 -65
- package/src/session-brief.ts +19 -7
- package/src/setup-drift.ts +217 -0
- package/src/setup-jcode.ts +65 -0
- package/src/setup-owned.ts +18 -1
- package/src/setup-paths.ts +76 -0
- package/src/setup.ts +92 -47
package/src/hook-spool.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { assertFormatFloor, FormatFloorError } from "./memory/formats.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import type { CaptureAppend, CaptureInput } from "./memory/capture.ts";
|
|
6
|
-
import { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type NormalizedCaptureFields } from "./memory/capture-input.ts";
|
|
7
|
+
import { CAPTURE_MAX_EXCERPT, clipCaptureUtf8, normalizeCaptureFields, toolCaptureExcerpt, toolInputLine, type CaptureFields, type NormalizedCaptureFields } from "./memory/capture-input.ts";
|
|
7
8
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
8
9
|
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
9
10
|
import { configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
|
|
10
11
|
import { fold } from "./memory/policy.ts";
|
|
12
|
+
import { stripPrivateSpans } from "./memory/private-spans.ts";
|
|
11
13
|
import { looksSecret } from "./memory/screen.ts";
|
|
12
14
|
import { appendMetrics, type CaptureDropReason } from "./memory/metrics.ts";
|
|
13
15
|
import { projectState } from "./memory/lifecycle.ts";
|
|
@@ -16,23 +18,40 @@ import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
|
|
|
16
18
|
// #199: v1 records (closed key set, no format field) are read, never written. Every 0.14-0.17 decoder deletes a record it cannot verify,
|
|
17
19
|
// so a record that adds a field publishes under a name the v1 pattern does not match (`hook-v2-`); old versions then ignore it
|
|
18
20
|
// (they still count it toward their file and byte caps). A later format that adds fields takes a new prefix again.
|
|
19
|
-
|
|
21
|
+
// #203: format 3 (`hook-v3-`) carries every record 0.18.0 cannot decode, today jcode-agent rows; 0.14-0.18 match neither
|
|
22
|
+
// `hook-` nor `hook-v2-` for it, so they leave it alone. Claude native rows stay format 2, byte for byte, so 0.18.0 keeps draining them.
|
|
23
|
+
const SCHEMA = "agent-mem.hook-capture/v1", FAMILY = "agent-mem.hook-capture", FORMAT = 2, PREFIX = "hook-v2-", FORMAT_V3 = 3, PREFIX_V3 = "hook-v3-";
|
|
20
24
|
const RECORD_BYTES = HOOK_INPUT_BYTES + 8192, MAX_FILES = 256, MAX_BYTES = 2 * 1024 * 1024;
|
|
21
|
-
|
|
25
|
+
// One hook previously performed one replay of at most 32 files / 256 KiB.
|
|
26
|
+
// Interleaved subpasses share these same ceilings; they do not each get a fresh allowance.
|
|
27
|
+
export const HOOK_REPLAY_MAX_FILES = 32, HOOK_REPLAY_MAX_BYTES = 256 * 1024;
|
|
28
|
+
const DRAIN_FILES = HOOK_REPLAY_MAX_FILES, DRAIN_BYTES = HOOK_REPLAY_MAX_BYTES;
|
|
29
|
+
export interface HookReplayBudget { files: number; bytes: number; seen: Set<string> }
|
|
30
|
+
export const hookReplayBudget = (): HookReplayBudget => ({ files: HOOK_REPLAY_MAX_FILES, bytes: HOOK_REPLAY_MAX_BYTES, seen: new Set() });
|
|
22
31
|
// ponytail: a finite native admission reserve covers deferred replay, not unlimited locked intake.
|
|
23
32
|
// Upgrade: coordinated append-only capacity accounting and observable backpressure at exhaustion.
|
|
24
33
|
const CAPACITY_FILES = 32, CAPACITY_BYTES = 256 * 1024;
|
|
25
|
-
const namePattern = /^(hook-(?:
|
|
34
|
+
const namePattern = /^(hook-(?:v[23]-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
|
|
26
35
|
const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex");
|
|
36
|
+
const summaryEvent = (event: string) => ["Title", "Recap", "Compact"].includes(event);
|
|
27
37
|
interface Record { schema: string; format?: number; cwd: string; scope: string | null; root?: string; nonce: string; capture: NormalizedCaptureFields }
|
|
28
38
|
export interface PendingHookCapture { name: string; cwd: string; receipt: string; deferred?: true; backlog?: true }
|
|
29
39
|
export class HookSpoolFull extends Error { constructor() { super("hook storage full"); } }
|
|
30
|
-
|
|
40
|
+
/** #203: the agent a staged record belongs to. Claude hooks and jcode lifecycle observers share this spool, budget and replay. */
|
|
41
|
+
export const HOOK_AGENTS = ["claude", "jcode"] as const;
|
|
42
|
+
export type HookAgent = typeof HOOK_AGENTS[number];
|
|
43
|
+
interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true; agent?: HookAgent }
|
|
31
44
|
const directory = (base: string) => join(base, "hook-spool");
|
|
45
|
+
/** A hook below the home's hook-spool floor (#199 AC5) stages, drains and maintains nothing; it still never denies a native action. */
|
|
46
|
+
function belowFloor(base: string): boolean {
|
|
47
|
+
try { assertFormatFloor(base, "hook-spool"); return false; } catch (error) { if (error instanceof FormatFloorError) return true; throw error; }
|
|
48
|
+
}
|
|
32
49
|
function physicalRoot(cwd: string): string {
|
|
33
50
|
const stat = lstatSync(cwd);
|
|
34
51
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook root unavailable");
|
|
35
|
-
|
|
52
|
+
// macOS can assign a different st_dev after reboot. Keep the directory's inode
|
|
53
|
+
// and birth time; open-file ownership still compares dev as well as inode.
|
|
54
|
+
return hash(`${stat.ino}:${stat.birthtimeMs}`);
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
function privateDirectory(dir: string): void {
|
|
@@ -41,23 +60,92 @@ function privateDirectory(dir: string): void {
|
|
|
41
60
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook storage unavailable");
|
|
42
61
|
chmodSync(dir, 0o700);
|
|
43
62
|
}
|
|
44
|
-
function entries(base: string, capacity = false): { name: string; size: number; modified: number }[] {
|
|
63
|
+
function entries(base: string, capacity = false, coverage?: { complete: boolean }): { name: string; size: number; modified: number; dev: number; ino: number }[] {
|
|
64
|
+
if (coverage) coverage.complete = false;
|
|
45
65
|
const dir = capacity ? join(directory(base), ".capacity") : directory(base), maximum = capacity ? CAPACITY_FILES : MAX_FILES, stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
46
|
-
if (!stat) return [];
|
|
66
|
+
if (!stat) { if (coverage) coverage.complete = true; return []; }
|
|
47
67
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("hook storage unavailable");
|
|
48
|
-
const reader = opendirSync(dir), rows: { name: string; size: number; modified: number }[] = [];
|
|
68
|
+
const reader = opendirSync(dir), rows: { name: string; size: number; modified: number; dev: number; ino: number }[] = [];
|
|
49
69
|
try {
|
|
50
70
|
for (let scanned = 0; scanned <= maximum + 4; scanned++) {
|
|
51
|
-
const entry = reader.readSync(); if (!entry) return rows;
|
|
71
|
+
const entry = reader.readSync(); if (!entry) { if (coverage) coverage.complete = true; return rows; }
|
|
52
72
|
if ([".writing", ".quarantine", ".capacity", ".maintenance"].includes(entry.name) && entry.isDirectory()) continue;
|
|
53
73
|
if (rows.length === maximum) return rows;
|
|
54
74
|
const item = lstatSync(join(dir, entry.name), { throwIfNoEntry: false });
|
|
55
|
-
if (item) rows.push({ name: entry.name, size: item.size, modified: item.mtimeMs });
|
|
75
|
+
if (item) rows.push({ name: entry.name, size: item.size, modified: item.mtimeMs, dev: item.dev, ino: item.ino });
|
|
56
76
|
}
|
|
57
77
|
return rows;
|
|
58
78
|
} finally { reader.closeSync(); }
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
/** A bounded diagnostic count of this cwd's active publications (names only, no stat).
|
|
82
|
+
* Task 110 schedules replay by publication/backpressure progress, not this legacy count. */
|
|
83
|
+
export function hookPendingCount(cwd: string, base: string): number {
|
|
84
|
+
try {
|
|
85
|
+
const dir = directory(canonicalPath(base)), wanted = hash(realPath(cwd)), reader = opendirSync(dir);
|
|
86
|
+
let count = 0;
|
|
87
|
+
try {
|
|
88
|
+
for (let scanned = 0; scanned < MAX_FILES; scanned++) {
|
|
89
|
+
const entry = reader.readSync(); if (!entry) break;
|
|
90
|
+
const match = namePattern.exec(entry.name);
|
|
91
|
+
if (match && match[2] === wanted) count++;
|
|
92
|
+
}
|
|
93
|
+
} finally { reader.closeSync(); }
|
|
94
|
+
return count;
|
|
95
|
+
} catch { return 0; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read-only bounded metadata inventory. Counts are lower bounds when complete is false. */
|
|
99
|
+
export function readHookSpoolStatus(base: string) {
|
|
100
|
+
assertFormatFloor(base, "hook-spool");
|
|
101
|
+
let claimed = 0, oldest = Infinity, complete = true;
|
|
102
|
+
const publications: { folder: string; name: string; size: number; modified: number }[] = [];
|
|
103
|
+
const tier = (capacity: boolean) => {
|
|
104
|
+
const dir = capacity ? join(directory(base), ".capacity") : directory(base);
|
|
105
|
+
const maxFiles = capacity ? CAPACITY_FILES : MAX_FILES, maxBytes = capacity ? CAPACITY_BYTES : MAX_BYTES;
|
|
106
|
+
const named = lstatSync(dir, { throwIfNoEntry: false });
|
|
107
|
+
let files = 0, bytes = 0;
|
|
108
|
+
if (!named) return { files, bytes, maxFiles, maxBytes };
|
|
109
|
+
if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("hook storage unavailable");
|
|
110
|
+
const reader = opendirSync(dir);
|
|
111
|
+
try {
|
|
112
|
+
for (let scanned = 0; scanned <= maxFiles + 4; scanned++) {
|
|
113
|
+
const entry = reader.readSync();
|
|
114
|
+
if (!entry) return { files, bytes, maxFiles, maxBytes };
|
|
115
|
+
if (!capacity && [".writing", ".quarantine", ".capacity", ".maintenance"].includes(entry.name) && entry.isDirectory()) continue;
|
|
116
|
+
if (files === maxFiles) break;
|
|
117
|
+
const item = lstatSync(join(dir, entry.name), { throwIfNoEntry: false });
|
|
118
|
+
if (!item) { complete = false; continue; }
|
|
119
|
+
files++; bytes += item.size; oldest = Math.min(oldest, item.mtimeMs);
|
|
120
|
+
publications.push({ folder: dir, name: entry.name, size: item.size, modified: item.mtimeMs });
|
|
121
|
+
if (namePattern.test(entry.name) && entry.name.includes(".draining-")) claimed++;
|
|
122
|
+
}
|
|
123
|
+
complete = false;
|
|
124
|
+
return { files, bytes, maxFiles, maxBytes };
|
|
125
|
+
} finally { reader.closeSync(); }
|
|
126
|
+
};
|
|
127
|
+
const active = tier(false), reserve = tier(true), budget = maintenanceBudget();
|
|
128
|
+
let native = 0, summaries = 0;
|
|
129
|
+
for (const row of publications) {
|
|
130
|
+
const match = namePattern.exec(row.name);
|
|
131
|
+
if (!match || row.size > RECORD_BYTES) continue;
|
|
132
|
+
if (!budget.files || row.size > budget.bytes) break;
|
|
133
|
+
budget.files--; budget.bytes -= row.size;
|
|
134
|
+
let file: OwnedFile | undefined;
|
|
135
|
+
try {
|
|
136
|
+
file = openOwned(join(row.folder, row.name));
|
|
137
|
+
if (file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) continue;
|
|
138
|
+
const record = decoded(file);
|
|
139
|
+
if (!record || record.unknown || hash(record.value.cwd) !== match[2] || (record.value.scope ?? "0".repeat(64)) !== match[3] || record.value.nonce !== match[4]) continue;
|
|
140
|
+
if (summaryEvent(record.value.capture.event)) summaries++; else native++;
|
|
141
|
+
} catch { /* Body classification stays unknown; metadata does not become a false zero. */ }
|
|
142
|
+
finally { if (file) closeSync(file.fd); }
|
|
143
|
+
}
|
|
144
|
+
const unclassified = active.files + reserve.files - native - summaries;
|
|
145
|
+
const kinds = { native, summaries, unclassified, complete: complete && unclassified === 0 };
|
|
146
|
+
return { active, reserve, claimed, oldestAt: Number.isFinite(oldest) ? new Date(oldest).toISOString() : null, complete, kinds };
|
|
147
|
+
}
|
|
148
|
+
|
|
61
149
|
/** Validate ordinary Git value syntax even for ignored keys: invalid unused configuration can make
|
|
62
150
|
* native Git refuse the whole file, so ignoring its syntax could falsely certify project resolution. */
|
|
63
151
|
function validateConfigValue(value: string): void {
|
|
@@ -128,11 +216,16 @@ function identityConfig(text: string, include?: (path: string, condition?: strin
|
|
|
128
216
|
* ponytail: strict unsupported Git configuration remains unknown, not guessed. A native historical-origin
|
|
129
217
|
* receipt would broaden that supported subset without relaxing the physical replacement boundary. */
|
|
130
218
|
export function hookCaptureScope(cwd: string, base: string, cacheValidity = false, extraGitConfig?: string): string | undefined {
|
|
219
|
+
return captureScope(cwd, base, cacheValidity, extraGitConfig, false);
|
|
220
|
+
}
|
|
221
|
+
/** 0.19.0 and older write a device-bound scope. Only its current-boot value is knowable;
|
|
222
|
+
* never guess an old device or silently rebind a record whose HOME/Git identity changed. */
|
|
223
|
+
function captureScope(cwd: string, base: string, cacheValidity: boolean, extraGitConfig: string | undefined, legacyDevice: boolean): string | undefined {
|
|
131
224
|
try {
|
|
132
225
|
const parts: string[] = [], identity = (file: string) => {
|
|
133
226
|
const stat = lstatSync(file);
|
|
134
227
|
if (!stat.isDirectory()) throw new Error("physical scope unavailable");
|
|
135
|
-
return `${stat.dev}
|
|
228
|
+
return `${legacyDevice || cacheValidity ? `${stat.dev}:` : ""}${stat.ino}:${stat.birthtimeMs}`;
|
|
136
229
|
};
|
|
137
230
|
const content = (file: string, maximum = 64 * 1024): string => {
|
|
138
231
|
const named = lstatSync(file, { throwIfNoEntry: false });
|
|
@@ -276,7 +369,8 @@ function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: tru
|
|
|
276
369
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Record;
|
|
277
370
|
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.cwd !== "string" || typeof value.nonce !== "string" || !/^[a-f0-9]{32}$/.test(value.nonce)
|
|
278
371
|
|| value.scope !== null && (typeof value.scope !== "string" || !/^[a-f0-9]{64}$/.test(value.scope))) return undefined;
|
|
279
|
-
const v1 = value.schema === SCHEMA && !("format" in value),
|
|
372
|
+
const v1 = value.schema === SCHEMA && !("format" in value), v3 = value.schema === FAMILY && value.format === FORMAT_V3;
|
|
373
|
+
const current = value.schema === FAMILY && value.format === FORMAT || v3;
|
|
280
374
|
if (!v1 && !current) return typeof value.schema === "string" ? { value, bytes, unknown: true } : undefined;
|
|
281
375
|
const keys = current ? 1 : 0;
|
|
282
376
|
if (![5 + keys, 6 + keys].includes(Object.keys(value).length)
|
|
@@ -285,7 +379,7 @@ function decoded(file: OwnedFile): { value: Record; bytes: Buffer; unknown?: tru
|
|
|
285
379
|
const c = value.capture;
|
|
286
380
|
if (!c || typeof c !== "object" || Array.isArray(c) || Object.keys(c).length < 8 || Object.keys(c).length > 10
|
|
287
381
|
|| Object.keys(c).some(key => !["at", "agent", "event", "tool", "paths", "status", "excerpt", "bytes", "session", "subagent"].includes(key))
|
|
288
|
-
|| c.agent
|
|
382
|
+
|| !(HOOK_AGENTS as readonly string[]).includes(c.agent) || (c.agent === "claude" && !["Title", "Recap", "Compact"].includes(c.event)) === v3 || ![...HOOK_EVENTS, "Title", "Recap", "Compact"].includes(c.event)
|
|
289
383
|
|| typeof c.at !== "string" || !Number.isFinite(Date.parse(c.at)) || typeof c.excerpt !== "string"
|
|
290
384
|
|| !Array.isArray(c.paths) || c.paths.some(path => typeof path !== "string")) return undefined;
|
|
291
385
|
const normalized = normalizeCaptureFields({ agent: c.agent, event: c.event, at: c.at, paths: c.paths, excerpt: c.excerpt,
|
|
@@ -353,11 +447,13 @@ function retireClaim(base: string, file: OwnedFile, preserve: boolean, batch?: Q
|
|
|
353
447
|
return true;
|
|
354
448
|
}
|
|
355
449
|
|
|
356
|
-
const RETIRE_AFTER_MS = 7 * 24 * 3600_000;
|
|
450
|
+
const RETIRE_AFTER_MS = 7 * 24 * 3600_000, MISSING_CWD_AFTER_MS = 3600_000;
|
|
451
|
+
interface MaintenanceBudget { files: number; bytes: number }
|
|
452
|
+
const maintenanceBudget = (): MaintenanceBudget => ({ files: DRAIN_FILES, bytes: DRAIN_BYTES });
|
|
357
453
|
/** Private numeric maintenance cursor: each invocation inspects a different bounded slice. */
|
|
358
|
-
function retirementCursor(base: string, capacity: boolean, length: number): { start: number; save(n: number): void } {
|
|
454
|
+
function retirementCursor(base: string, capacity: boolean, length: number, pressure = false): { start: number; save(n: number): void } {
|
|
359
455
|
const dir = join(directory(base), ".maintenance"); privateDirectory(dir);
|
|
360
|
-
const name = join(dir, capacity ? "reserve" : "active");
|
|
456
|
+
const name = join(dir, `${capacity ? "reserve" : "active"}${pressure ? "-pressure" : ""}`);
|
|
361
457
|
let start = 0, file: OwnedFile | undefined;
|
|
362
458
|
try {
|
|
363
459
|
file = openOwned(name);
|
|
@@ -373,62 +469,125 @@ function retirementCursor(base: string, capacity: boolean, length: number): { st
|
|
|
373
469
|
finally { rmSync(temporary, { force: true }); }
|
|
374
470
|
} };
|
|
375
471
|
}
|
|
376
|
-
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
|
|
380
|
-
|
|
472
|
+
/** Bounded, counted retirement: missing cwds after one hour, other unresolvable known
|
|
473
|
+
* scopes after seven days, and fair-share pressure. Newer schemas retain their seven-day
|
|
474
|
+
* protection. A semantic/physical scope mismatch alone never proves a cwd was replaced.
|
|
475
|
+
* ponytail: fair shares are cooperative admission, not an atomic multi-process quota.
|
|
476
|
+
* Upgrade: coordinated byte/file reservations with crash recovery. */
|
|
477
|
+
function retireUnresolved(base: string, admittingCwd: string | undefined, inventory: ReturnType<typeof entries>, incomingBytes: number, minimal: boolean, capacity = false, budget = maintenanceBudget(), scanLimit = DRAIN_FILES): Set<string> {
|
|
478
|
+
const retired = new Set<string>(), cwdHash = admittingCwd === undefined ? undefined : hash(admittingCwd);
|
|
381
479
|
let count = inventory.length, bytes = inventory.reduce((sum, row) => sum + row.size, 0);
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
|
|
480
|
+
const maximum = capacity ? CAPACITY_FILES : MAX_FILES, byteMaximum = capacity ? CAPACITY_BYTES : MAX_BYTES, share = maximum / 4;
|
|
481
|
+
const groups = new Map<string, number>();
|
|
482
|
+
for (const row of inventory) {
|
|
483
|
+
const match = namePattern.exec(row.name); if (match) groups.set(match[2]!, (groups.get(match[2]!) ?? 0) + 1);
|
|
484
|
+
}
|
|
485
|
+
const quota = () => cwdHash !== undefined && (groups.get(cwdHash) ?? 0) >= share;
|
|
486
|
+
const pressure = () => cwdHash !== undefined && (quota() || count >= maximum || bytes + incomingBytes > byteMaximum);
|
|
487
|
+
const due = inventory.some(row => Date.now() - row.modified >= MISSING_CWD_AFTER_MS);
|
|
488
|
+
const pressured = pressure();
|
|
489
|
+
if (!inventory.length || minimal && !pressured) return retired;
|
|
385
490
|
const active = [...inventory].sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
|
|
386
491
|
let cursor: ReturnType<typeof retirementCursor>;
|
|
387
|
-
try { cursor = retirementCursor(base, capacity, active.length); } catch { return retired; }
|
|
388
|
-
let
|
|
492
|
+
try { cursor = retirementCursor(base, capacity, active.length, pressured); } catch { return retired; }
|
|
493
|
+
let scanned = 0;
|
|
389
494
|
const scopes = new Map<string, string | undefined>();
|
|
495
|
+
// Pressure first visits the largest share; HOME variants of one cwd share one allowance.
|
|
496
|
+
const ordered = pressure() ? [...active].sort((a, b) => {
|
|
497
|
+
const group = (row: typeof a) => namePattern.exec(row.name)?.[2];
|
|
498
|
+
const score = (row: typeof a) => { const key = group(row); return quota() ? Number(key === cwdHash) * (maximum + 1) : groups.get(key ?? "") ?? 0; };
|
|
499
|
+
return score(b) - score(a) || a.modified - b.modified || a.name.localeCompare(b.name);
|
|
500
|
+
}) : active;
|
|
501
|
+
const candidates = ordered.slice(cursor.start).concat(ordered.slice(0, cursor.start));
|
|
502
|
+
const held: { file: OwnedFile; row: typeof inventory[number]; group: string }[] = [];
|
|
503
|
+
let classified = 0, nativeSeen = false;
|
|
390
504
|
try {
|
|
391
|
-
for (
|
|
392
|
-
if (
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
505
|
+
for (const row of candidates) {
|
|
506
|
+
if (!budget.files || scanned >= scanLimit || !pressure() && (minimal || pressured && !due)) break;
|
|
507
|
+
const match = namePattern.exec(row.name); if (!match) { scanned++; continue; }
|
|
508
|
+
if (row.size <= RECORD_BYTES && row.size > budget.bytes) break;
|
|
509
|
+
budget.files--; scanned++;
|
|
396
510
|
let file: OwnedFile | undefined;
|
|
397
511
|
try {
|
|
398
512
|
file = openOwned(join(capacity ? join(directory(base), ".capacity") : directory(base), row.name));
|
|
399
513
|
if (file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) continue;
|
|
400
|
-
if (row.size <= RECORD_BYTES)
|
|
514
|
+
if (row.size <= RECORD_BYTES) budget.bytes -= row.size;
|
|
401
515
|
const record = decoded(file);
|
|
402
516
|
const valid = record && hash(record.value.cwd) === match[2] && record.value.nonce === match[4] && (record.value.scope ?? "0".repeat(64)) === match[3];
|
|
403
517
|
let reason: CaptureDropReason | undefined;
|
|
404
518
|
if (valid && record.unknown) {
|
|
405
|
-
// #199: a newer writer's record waits for a reader that knows it, then leaves with a counted reason, never at once.
|
|
406
519
|
if (Date.now() - row.modified < RETIRE_AFTER_MS) continue;
|
|
407
520
|
reason = "unknown_schema_expired";
|
|
408
521
|
} else if (valid) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
522
|
+
classified++;
|
|
523
|
+
const summary = summaryEvent(record.value.capture.event);
|
|
524
|
+
if (!summary) nativeSeen = true;
|
|
525
|
+
const age = Date.now() - Date.parse(record.value.capture.at);
|
|
526
|
+
if (age >= MISSING_CWD_AFTER_MS && Date.now() - row.modified >= MISSING_CWD_AFTER_MS) {
|
|
527
|
+
try { lstatSync(record.value.cwd); }
|
|
528
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") reason = "cwd_removed"; }
|
|
529
|
+
}
|
|
530
|
+
if (!reason && age >= RETIRE_AFTER_MS) {
|
|
413
531
|
if (record.value.scope === null) reason = "scope_unknown";
|
|
414
532
|
else {
|
|
415
533
|
if (!scopes.has(record.value.cwd)) scopes.set(record.value.cwd, hookCaptureScope(record.value.cwd, base));
|
|
416
534
|
const currentScope = scopes.get(record.value.cwd);
|
|
417
535
|
if (currentScope === undefined) reason = "scope_unknown";
|
|
418
|
-
else if (currentScope !== record.value.scope) reason = "undrainable_expired";
|
|
536
|
+
else if (currentScope !== record.value.scope && captureScope(record.value.cwd, base, false, undefined, true) !== record.value.scope) reason = "undrainable_expired";
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (!reason && pressure()) {
|
|
540
|
+
if (summary) {
|
|
541
|
+
// An advanced transcript cursor relies on accepted summaries. Hold their
|
|
542
|
+
// descriptors only to prove a complete all-summary foreign inventory.
|
|
543
|
+
if (!quota()) { held.push({ file, row, group: match[2]! }); file = undefined; }
|
|
544
|
+
continue;
|
|
419
545
|
}
|
|
546
|
+
if (!quota() || match[2] === cwdHash) reason = "scope_capacity";
|
|
420
547
|
}
|
|
421
548
|
if (!reason) continue;
|
|
422
549
|
}
|
|
423
550
|
const claim = claimOwned(base, row.name, file);
|
|
424
551
|
if (!claim || !owns(claim)) continue;
|
|
425
|
-
|
|
552
|
+
if (reason === "cwd_removed") {
|
|
553
|
+
try { lstatSync(record!.value.cwd); continue; }
|
|
554
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue; }
|
|
555
|
+
}
|
|
556
|
+
// Persist the numeric loss receipt BEFORE removing a replayable body.
|
|
426
557
|
if (reason) appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: reason }]);
|
|
427
|
-
if (retireClaim(base, claim, false)) {
|
|
558
|
+
if (retireClaim(base, claim, false)) {
|
|
559
|
+
retired.add(row.name); count--; bytes -= row.size;
|
|
560
|
+
groups.set(match[2]!, Math.max(0, (groups.get(match[2]!) ?? 0) - 1));
|
|
561
|
+
}
|
|
428
562
|
} catch { /* Transient I/O and changed evidence stay pending. */ }
|
|
429
563
|
finally { if (file) closeSync(file.fd); }
|
|
430
564
|
}
|
|
431
|
-
|
|
565
|
+
// ponytail: a cap of summaries from four or more orphaned cwds can still
|
|
566
|
+
// refuse intake until TTL/replay: 32 files/256 KiB cannot prove native absence
|
|
567
|
+
// across the whole cap. Upgrade: a bounded derived classification cache.
|
|
568
|
+
if (pressure() && !quota() && !nativeSeen && classified === inventory.length && held.length === inventory.length
|
|
569
|
+
&& held.every(item => owns(item.file))) {
|
|
570
|
+
const coverage = { complete: false }, current = entries(base, capacity, coverage);
|
|
571
|
+
if (coverage.complete && current.length === inventory.length && current.every(row => inventory.some(prior => prior.name === row.name && prior.size === row.size && prior.modified === row.modified))) {
|
|
572
|
+
const victims = held.filter(item => item.group !== cwdHash).sort((a, b) => (groups.get(b.group) ?? 0) - (groups.get(a.group) ?? 0) || a.row.modified - b.row.modified || a.row.name.localeCompare(b.row.name));
|
|
573
|
+
for (const victim of victims) {
|
|
574
|
+
if (!pressure()) break;
|
|
575
|
+
try {
|
|
576
|
+
const claim = claimOwned(base, victim.row.name, victim.file);
|
|
577
|
+
if (!claim || !owns(claim)) continue;
|
|
578
|
+
appendMetrics(base, [{ at: new Date().toISOString(), kind: "hook", captureDrops: 1, captureDropReason: "summary_capacity" }]);
|
|
579
|
+
if (retireClaim(base, claim, false)) {
|
|
580
|
+
retired.add(victim.row.name); count--; bytes -= victim.row.size;
|
|
581
|
+
groups.set(victim.group, Math.max(0, (groups.get(victim.group) ?? 0) - 1));
|
|
582
|
+
}
|
|
583
|
+
} catch { /* Failed counting or changed ownership preserves the body. */ }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
} finally {
|
|
588
|
+
for (const item of held) closeSync(item.file.fd);
|
|
589
|
+
try { cursor.save(retired.size ? 0 : cursor.start + Math.max(1, scanned)); } catch { /* A derived cursor never acknowledges capture. */ }
|
|
590
|
+
}
|
|
432
591
|
return retired;
|
|
433
592
|
}
|
|
434
593
|
|
|
@@ -436,7 +595,7 @@ function retireUnresolved(base: string, _admittingCwd: string | undefined, inven
|
|
|
436
595
|
* discard a valid pending record; only positively invalid publications leave admission. */
|
|
437
596
|
export function quarantineHookScope(options: { cwd: string; base: string; scope: string }): { retired: number } {
|
|
438
597
|
let retired = 0;
|
|
439
|
-
if (!/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
|
|
598
|
+
if (belowFloor(options.base) || !/^[a-f0-9]{64}$/.test(options.scope)) return { retired };
|
|
440
599
|
const cwd = realPath(options.cwd), base = canonicalPath(options.base), cwdHash = hash(cwd);
|
|
441
600
|
trimQuarantine(base);
|
|
442
601
|
const rows = entries(base).filter(row => {
|
|
@@ -462,7 +621,7 @@ export function quarantineHookScope(options: { cwd: string; base: string; scope:
|
|
|
462
621
|
|
|
463
622
|
/** Only screened normalized capture fields are retained, never the native input envelope. */
|
|
464
623
|
export async function stageHookCapture(event: string, input: unknown, options: Options): Promise<PendingHookCapture | undefined> {
|
|
465
|
-
if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host) return undefined;
|
|
624
|
+
if (!(HOOK_EVENTS as readonly string[]).includes(event) || options.policy?.host || belowFloor(options.base)) return undefined;
|
|
466
625
|
const policy = createStandalonePolicy(configuredPolicyOptions(options.base, options.cwd, options.policy));
|
|
467
626
|
if (!await policy.screen(input)) return undefined;
|
|
468
627
|
const value = structuredClone(input) as {[key: string]: unknown};
|
|
@@ -485,7 +644,7 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
485
644
|
// a non-empty agent_id marks a subagent (or in-process teammate) event. Both are read from the already-screened input.
|
|
486
645
|
const session = typeof value.session_id === "string" && value.session_id.length > 0 && value.session_id.length <= 256 ? hash(`amem-session\0${value.session_id}`).slice(0, 16) : undefined;
|
|
487
646
|
const subagent = typeof value.agent_id === "string" && value.agent_id.length > 0;
|
|
488
|
-
const capture = normalizeCaptureFields({ agent: "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
|
|
647
|
+
const capture = normalizeCaptureFields({ agent: options.agent ?? "claude", event, paths, status: event === "PostToolUseFailure" ? "failed" : "ok",
|
|
489
648
|
...(session ? { session } : {}), ...(subagent ? { subagent: true } : {}),
|
|
490
649
|
...(typeof value.tool_name === "string" ? { tool: value.tool_name } : {}), excerpt },
|
|
491
650
|
undefined,
|
|
@@ -495,37 +654,92 @@ export async function stageHookCapture(event: string, input: unknown, options: O
|
|
|
495
654
|
// Enriched tool rows are refused rather than persisting the normalizer's redaction marker.
|
|
496
655
|
if (toolEvent && capture.excerpt === "[secret-screened]") return undefined;
|
|
497
656
|
if (!await policy.screen({ cwd, capture })) return undefined;
|
|
498
|
-
|
|
499
|
-
|
|
657
|
+
return publishCapture(event, capture, { ...options, cwd, base });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Transcript text is screened in full before caps, then shares the ordinary durable spool. */
|
|
661
|
+
export async function stageTranscriptCapture(input: CaptureFields, options: Options, receiptIdentity?: string): Promise<PendingHookCapture | undefined> {
|
|
662
|
+
if (options.policy?.host || belowFloor(options.base) || input.agent !== "claude" || !["Title", "Recap", "Compact"].includes(input.event)) return;
|
|
663
|
+
const cwd = realPath(options.cwd), base = canonicalPath(options.base);
|
|
664
|
+
const policy = createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
|
|
665
|
+
if (!await policy.screen({ cwd, input })) return;
|
|
666
|
+
const configured = loadAmemConfig(base, repoConfigRoot(cwd)).config;
|
|
667
|
+
const capture = normalizeCaptureFields({ ...input, excerpt: clipCaptureUtf8(stripPrivateSpans(input.excerpt ?? ""), Math.min(CAPTURE_MAX_EXCERPT, configured.excerptBytes ?? CAPTURE_MAX_EXCERPT)) }, undefined,
|
|
668
|
+
text => looksSecret(text) || configured.piiPatterns.some(pattern => fold(text).includes(fold(pattern))),
|
|
669
|
+
{ excerptBytes: configured.excerptBytes, paths: configured.capturePaths, denyPaths: configured.denyPaths });
|
|
670
|
+
if (capture.excerpt === "[secret-screened]" || !await policy.screen({ cwd, capture })) return;
|
|
671
|
+
return publishCapture(input.event, capture, { ...options, cwd, base }, hash(receiptIdentity === undefined ? JSON.stringify(capture) : JSON.stringify([capture, receiptIdentity])).slice(0, 32));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function publishCapture(event: string, capture: NormalizedCaptureFields, options: Options, nonce = randomBytes(16).toString("hex")): PendingHookCapture {
|
|
675
|
+
const { cwd, base } = options;
|
|
676
|
+
const scope = hookCaptureScope(cwd, base) ?? null;
|
|
677
|
+
const v3 = capture.agent !== "claude" || ["Title", "Recap", "Compact"].includes(event);
|
|
678
|
+
const record: Record = { schema: FAMILY, format: v3 ? FORMAT_V3 : FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
|
|
500
679
|
if (Buffer.byteLength(bytes) > RECORD_BYTES) throw new Error("hook record bound");
|
|
501
680
|
const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
|
|
502
681
|
let destination = dir, deferred = false, backlog = false;
|
|
682
|
+
const budget = maintenanceBudget(), incomingBytes = Buffer.byteLength(bytes), cwdHash = hash(cwd);
|
|
683
|
+
const withinShare = (rows: ReturnType<typeof entries>, maximum: number) => rows.filter(row => namePattern.exec(row.name)?.[2] === cwdHash).length < maximum / 4;
|
|
684
|
+
const name = `${v3 ? PREFIX_V3 : PREFIX}${cwdHash}-${scope ?? "0".repeat(64)}-${nonce}.json`;
|
|
685
|
+
let activeInventory: ReturnType<typeof entries> | undefined, reserveInventory: ReturnType<typeof entries> | undefined;
|
|
686
|
+
if (["Title", "Recap", "Compact"].includes(event)) {
|
|
687
|
+
// Transcript cursor recovery can repeat a deterministic publication. An already
|
|
688
|
+
// durable identical body needs no new slot, including when a drainer owns it.
|
|
689
|
+
for (const capacity of [false, true]) {
|
|
690
|
+
const inventory = entries(base, capacity);
|
|
691
|
+
if (capacity) reserveInventory = inventory; else activeInventory = inventory;
|
|
692
|
+
for (const row of inventory) {
|
|
693
|
+
if (!namePattern.test(row.name) || row.name !== name && !row.name.startsWith(name + ".draining-")) continue;
|
|
694
|
+
if (!budget.files || row.size > budget.bytes) break;
|
|
695
|
+
budget.files--; budget.bytes -= row.size;
|
|
696
|
+
let existing: OwnedFile | undefined;
|
|
697
|
+
try {
|
|
698
|
+
existing = openOwned(join(capacity ? join(dir, ".capacity") : dir, row.name));
|
|
699
|
+
if (existing.initial.size !== incomingBytes) continue;
|
|
700
|
+
const held = Buffer.alloc(incomingBytes);
|
|
701
|
+
if (readSync(existing.fd, held, 0, held.length, 0) === held.length && owns(existing) && held.equals(Buffer.from(bytes))) {
|
|
702
|
+
return { name, cwd, receipt: hash(bytes), backlog: true, ...(capacity ? { deferred: true as const } : {}) };
|
|
703
|
+
}
|
|
704
|
+
} catch { /* Changed or unavailable evidence is not acknowledgement. */ }
|
|
705
|
+
finally { if (existing) closeSync(existing.fd); }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
503
709
|
if (event === "SessionStart") {
|
|
504
710
|
// ponytail: reserve-only admission scans at most 32 metadata entries, not O(1).
|
|
505
711
|
// Never inspect/retire the active tier on the brief deadline. Fixed-slot byte
|
|
506
712
|
// reservations with crash recovery are the upgrade if this bound is too costly.
|
|
507
713
|
destination = join(dir, ".capacity"); privateDirectory(destination);
|
|
508
|
-
const
|
|
509
|
-
|
|
714
|
+
const inventory = entries(base, true);
|
|
715
|
+
const retired = retireUnresolved(base, cwd, inventory, incomingBytes, true, true, budget);
|
|
716
|
+
const reserved = inventory.filter(row => !retired.has(row.name));
|
|
717
|
+
if (!withinShare(reserved, CAPACITY_FILES) || reserved.length >= CAPACITY_FILES || reserved.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > CAPACITY_BYTES) throw new HookSpoolFull();
|
|
510
718
|
deferred = true; backlog = reserved.length > 0;
|
|
511
719
|
} else {
|
|
512
|
-
const inventory = entries(base)
|
|
513
|
-
|
|
720
|
+
const inventory = activeInventory ?? entries(base);
|
|
721
|
+
if (summaryEvent(event) && (!withinShare(inventory, MAX_FILES) || inventory.length >= MAX_FILES || inventory.reduce((sum, row) => sum + row.size, 0) + incomingBytes > MAX_BYTES)) throw new HookSpoolFull();
|
|
722
|
+
let reserved = reserveInventory ?? entries(base, true);
|
|
723
|
+
// Split a nonempty two-tier backlog's inspection budget so neither tier starves.
|
|
724
|
+
const retired = retireUnresolved(base, cwd, inventory, incomingBytes, false, false, budget, reserved.length ? DRAIN_FILES / 2 : DRAIN_FILES);
|
|
514
725
|
const queued = inventory.filter(row => !retired.has(row.name));
|
|
515
|
-
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
726
|
+
const activeFits = withinShare(queued, MAX_FILES) && queued.length < MAX_FILES && queued.reduce((sum, row) => sum + row.size, 0) + incomingBytes <= MAX_BYTES;
|
|
727
|
+
// If active admission failed, reserve pressure gets the remaining budget first.
|
|
728
|
+
if (activeFits) {
|
|
729
|
+
const retiredCapacity = retireUnresolved(base, undefined, reserved, 0, false, true, budget);
|
|
730
|
+
reserved = reserved.filter(row => !retiredCapacity.has(row.name));
|
|
731
|
+
}
|
|
520
732
|
backlog = queued.length > 0 || reserved.length > 0;
|
|
521
|
-
if (
|
|
733
|
+
if (!activeFits) {
|
|
522
734
|
if (options.deferAtCapacity !== true) throw new HookSpoolFull();
|
|
523
735
|
destination = join(dir, ".capacity"); privateDirectory(destination);
|
|
524
|
-
|
|
736
|
+
const retiredReserve = retireUnresolved(base, cwd, reserved, incomingBytes, true, true, budget);
|
|
737
|
+
reserved = reserved.filter(row => !retiredReserve.has(row.name));
|
|
738
|
+
if (!withinShare(reserved, CAPACITY_FILES) || reserved.length >= CAPACITY_FILES || reserved.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > CAPACITY_BYTES) throw new HookSpoolFull();
|
|
525
739
|
deferred = true;
|
|
526
740
|
}
|
|
527
741
|
}
|
|
528
|
-
const
|
|
742
|
+
const temporary = join(writing, name);
|
|
529
743
|
try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
|
|
530
744
|
finally { rmSync(temporary, { force: true }); }
|
|
531
745
|
return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
|
|
@@ -540,31 +754,40 @@ export function hookCapturePending(base: string, name: string): boolean {
|
|
|
540
754
|
} catch { return true; } // Unreadable evidence is pending, never an acknowledgement.
|
|
541
755
|
}
|
|
542
756
|
|
|
543
|
-
export async function drainHookCaptures(options: Options & { project: string; expectedScope?: string; current?: string; timeRemaining?: () => number },
|
|
757
|
+
export async function drainHookCaptures(options: Options & { project: string; expectedScope?: string; current?: string; timeRemaining?: () => number; replayBudget?: HookReplayBudget },
|
|
544
758
|
append: (input: CaptureInput, receipt: string) => CaptureAppend): Promise<{ captured: string[]; blocked: number }> {
|
|
759
|
+
const budget = options.replayBudget ?? hookReplayBudget();
|
|
760
|
+
if (!Number.isSafeInteger(budget.files) || budget.files < 0 || budget.files > HOOK_REPLAY_MAX_FILES
|
|
761
|
+
|| !Number.isSafeInteger(budget.bytes) || budget.bytes < 0 || budget.bytes > HOOK_REPLAY_MAX_BYTES
|
|
762
|
+
|| !(budget.seen instanceof Set) || budget.seen.size > HOOK_REPLAY_MAX_FILES) throw new Error("hook replay budget is invalid; create a fresh hookReplayBudget for this hook");
|
|
545
763
|
const cwd = realPath(options.cwd), base = canonicalPath(options.base), stamp = hookCaptureScope(cwd, base), captured: string[] = [];
|
|
764
|
+
if (belowFloor(base)) return { captured, blocked: 1 };
|
|
546
765
|
if (!stamp || options.expectedScope !== undefined && options.expectedScope !== stamp) return { captured, blocked: 1 };
|
|
547
766
|
// F4: the configured policy applies at drain too. `options.policy` carries it, so the anchored line below (a test diagnostic matches its exact text) stays as it was.
|
|
548
767
|
options = { ...options, policy: configuredPolicyOptions(base, cwd, options.policy) };
|
|
549
768
|
const policy = createStandalonePolicy(options.policy), dir = directory(base);
|
|
769
|
+
const legacyStamp = captureScope(cwd, base, false, undefined, true);
|
|
770
|
+
const accepts = (scope: string | null) => scope === stamp || legacyStamp !== undefined && scope === legacyStamp;
|
|
550
771
|
const matching = (rows: ReturnType<typeof entries>, capacity: boolean) => rows.filter(row => {
|
|
551
|
-
const m = namePattern.exec(row.name); return m && m[2] === hash(cwd) && m[3]
|
|
772
|
+
const m = namePattern.exec(row.name); return m && m[2] === hash(cwd) && accepts(m[3]!);
|
|
552
773
|
}).sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name)).map(row => ({ ...row, capacity }));
|
|
553
774
|
const rows = [...matching(entries(base, true), true), ...matching(entries(base), false)]
|
|
554
775
|
.sort((a, b) => a.modified - b.modified || a.name.localeCompare(b.name));
|
|
555
|
-
|
|
556
|
-
|
|
776
|
+
const identity = (row: typeof rows[number]) => `${namePattern.exec(row.name)![1]}:${row.dev}:${row.ino}`;
|
|
777
|
+
let remaining = budget.bytes, blocked = 0;
|
|
778
|
+
for (const row of rows.filter(row => !budget.seen.has(identity(row))).slice(0, budget.files)) {
|
|
557
779
|
if (options.timeRemaining && options.timeRemaining() < 8 || row.size <= RECORD_BYTES && row.size > remaining) break;
|
|
558
780
|
const match = namePattern.exec(row.name)!, name = match[1]!;
|
|
781
|
+
budget.files--; budget.seen.add(identity(row));
|
|
559
782
|
let file: OwnedFile | undefined;
|
|
560
783
|
try {
|
|
561
784
|
file = openOwned(join(row.capacity ? join(dir, ".capacity") : dir, row.name));
|
|
562
785
|
if (file.initial.size !== row.size || file.initial.mtimeMs !== row.modified) { blocked++; continue; }
|
|
563
786
|
const claim = claimOwned(base, row.name, file);
|
|
564
787
|
if (!claim) { blocked++; continue; }
|
|
565
|
-
if (claim.initial.size <= RECORD_BYTES) remaining -= claim.initial.size;
|
|
788
|
+
if (claim.initial.size <= RECORD_BYTES) { remaining -= claim.initial.size; budget.bytes -= claim.initial.size; }
|
|
566
789
|
const record = decoded(claim);
|
|
567
|
-
if (!record || record.value.cwd !== cwd || record.value.scope !==
|
|
790
|
+
if (!record || record.value.cwd !== cwd || record.value.scope !== match[3] || record.value.nonce !== match[4]) {
|
|
568
791
|
retireClaim(base, claim, false); blocked++; continue;
|
|
569
792
|
}
|
|
570
793
|
// #199: an unknown or newer schema stays claimed and pending (later passes reclaim it) until the TTL, then retires with a counted reason.
|
|
@@ -590,7 +813,7 @@ export async function drainHookCaptures(options: Options & { project: string; ex
|
|
|
590
813
|
...(c.tool === null ? {} : { tool: c.tool }), ...(c.status === null ? {} : { status: c.status }), excerpt: c.excerpt,
|
|
591
814
|
...(c.session === undefined ? {} : { session: c.session }), ...(c.subagent === undefined ? {} : { subagent: c.subagent }) };
|
|
592
815
|
if (!await policy.screen(value) || !await policy.screen(input)) { retireClaim(base, claim, true); blocked++; continue; }
|
|
593
|
-
if (!owns(claim) || hookCaptureScope(cwd, base) !== stamp) { blocked++; continue; }
|
|
816
|
+
if (!owns(claim) || hookCaptureScope(cwd, base) !== stamp || value.scope === legacyStamp && captureScope(cwd, base, false, undefined, true) !== legacyStamp) { blocked++; continue; }
|
|
594
817
|
append(input, hash(record.bytes));
|
|
595
818
|
if (owns(claim)) { rmSync(claim.file); captured.push(name); }
|
|
596
819
|
else blocked++;
|
|
@@ -599,3 +822,29 @@ export async function drainHookCaptures(options: Options & { project: string; ex
|
|
|
599
822
|
}
|
|
600
823
|
return { captured, blocked };
|
|
601
824
|
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* #217 PR2: the narrow internal surface the CLI-only replay (src/hook-spool-replay.ts) reuses, so replay keeps exactly the
|
|
828
|
+
* hook path's owned-file, decoder and claim rules. No format or ownership fork; the replay module is never imported by hooks.
|
|
829
|
+
*/
|
|
830
|
+
export const hookSpoolReplayInternals = {
|
|
831
|
+
namePattern, RECORD_BYTES, MAX_FILES,
|
|
832
|
+
openOwned: (file: string) => openOwned(file),
|
|
833
|
+
owns: (file: OwnedFile) => owns(file),
|
|
834
|
+
decoded: (file: OwnedFile) => decoded(file),
|
|
835
|
+
belowFloor: (base: string) => belowFloor(base),
|
|
836
|
+
/** Claim under a caller-chosen deterministic suffix (#217 decision B: hash(plan digest, publication)), never a random one. */
|
|
837
|
+
claimAs(file: OwnedFile, name: string, suffix: string): OwnedFile | undefined {
|
|
838
|
+
const match = namePattern.exec(name);
|
|
839
|
+
if (!match || !/^[a-f0-9]{24}$/.test(suffix) || !owns(file)) return undefined;
|
|
840
|
+
const claim = join(dirname(file.file), `${match[1]}.draining-${suffix}`);
|
|
841
|
+
renameSync(file.file, claim);
|
|
842
|
+
const owned = { ...file, file: claim };
|
|
843
|
+
return owns(owned) ? owned : undefined;
|
|
844
|
+
},
|
|
845
|
+
/** Remove exactly the owned claim, only while it is still the inspected inode. */
|
|
846
|
+
retireOwned(file: OwnedFile): boolean { if (!owns(file)) return false; rmSync(file.file); return true; },
|
|
847
|
+
};
|
|
848
|
+
export type HookSpoolOwnedFile = OwnedFile;
|
|
849
|
+
/** The 0.19.0-and-older device-bound scope for this cwd, computable only for the current boot (drain accepts it too). */
|
|
850
|
+
export function hookLegacyCaptureScope(cwd: string, base: string): string | undefined { return captureScope(cwd, base, false, undefined, true); }
|