@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/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
- const SCHEMA = "agent-mem.hook-capture/v1", FAMILY = "agent-mem.hook-capture", FORMAT = 2, PREFIX = "hook-v2-";
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
- const DRAIN_FILES = 32, DRAIN_BYTES = 256 * 1024;
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-(?:v2-)?([a-f0-9]{64})-([a-f0-9]{64})-([a-f0-9]{32})\.json)(?:\.draining-[a-f0-9]{24})?$/;
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
- interface Options { cwd: string; base: string; policy?: StandalonePolicyOptions; deferAtCapacity?: true }
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
- return hash(`${stat.dev}:${stat.ino}:${stat.birthtimeMs}`);
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}:${stat.ino}:${stat.birthtimeMs}`;
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), current = value.schema === FAMILY && value.format === FORMAT;
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 !== "claude" || !(HOOK_EVENTS as readonly string[]).includes(c.event)
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
- /** ponytail: remove undrainable data only after seven days, at
377
- * most32 files/256KiB per admission. Count loss before deleting bodies. Upgrade:
378
- * a person-visible durable dead-letter export and coordinated maintenance accounting. */
379
- function retireUnresolved(base: string, _admittingCwd: string | undefined, inventory: ReturnType<typeof entries>, incomingBytes: number, minimal: boolean, capacity = false, _sharedBatch?: QuarantineBatch): Set<string> {
380
- const retired = new Set<string>();
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 fits = () => count < (capacity ? CAPACITY_FILES : MAX_FILES) && bytes + incomingBytes <= (capacity ? CAPACITY_BYTES : MAX_BYTES);
383
- const due = inventory.some(row => Date.now() - row.modified >= RETIRE_AFTER_MS);
384
- if (!inventory.length || minimal && fits() && !due) return retired;
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 remaining = DRAIN_BYTES, scanned = 0;
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 (; scanned < Math.min(DRAIN_FILES, active.length); scanned++) {
392
- if (minimal && fits() && !due) break;
393
- const row = active[(cursor.start + scanned) % active.length]!;
394
- const match = namePattern.exec(row.name); if (!match) continue;
395
- if (row.size <= RECORD_BYTES && row.size > remaining) break;
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) remaining -= row.size;
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
- if (Date.now() - Date.parse(record.value.capture.at) < RETIRE_AFTER_MS) continue;
410
- try { const cwd = lstatSync(record.value.cwd); if (!cwd.isDirectory() || cwd.isSymbolicLink()) reason = "cwd_removed"; }
411
- catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") reason = "cwd_removed"; }
412
- if (!reason) {
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
- // Failure to persist this numeric receipt keeps the body pending, never silently lost.
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)) { retired.add(row.name); count--; bytes -= row.size; }
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
- } finally { try { cursor.save(cursor.start + Math.max(1, scanned)); } catch { /* Derived cursor failure never deletes data. */ } }
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
- const scope = hookCaptureScope(cwd, base) ?? null, nonce = randomBytes(16).toString("hex");
499
- const record: Record = { schema: FAMILY, format: FORMAT, cwd, scope, root: physicalRoot(cwd), nonce, capture }, bytes = JSON.stringify(record);
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 reserved = entries(base, true);
509
- if (reserved.length >= CAPACITY_FILES || reserved.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > CAPACITY_BYTES) throw new HookSpoolFull();
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), quarantineBatch: QuarantineBatch = {};
513
- const retired = retireUnresolved(base, cwd, inventory, Buffer.byteLength(bytes), false, false, quarantineBatch);
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
- let reserved = entries(base, true);
516
- // Reserve maintenance must progress even while ordinary admission has space:
517
- // SessionStart never retires, so otherwise expired undrainable reserve data pins it.
518
- const retiredCapacity = retireUnresolved(base, cwd, reserved, Buffer.byteLength(bytes), true, true, quarantineBatch);
519
- reserved = reserved.filter(row => !retiredCapacity.has(row.name));
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 (queued.length >= MAX_FILES || queued.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > MAX_BYTES) {
733
+ if (!activeFits) {
522
734
  if (options.deferAtCapacity !== true) throw new HookSpoolFull();
523
735
  destination = join(dir, ".capacity"); privateDirectory(destination);
524
- if (reserved.length >= CAPACITY_FILES || reserved.reduce((sum, row) => sum + row.size, 0) + Buffer.byteLength(bytes) > CAPACITY_BYTES) throw new HookSpoolFull();
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 name = `${PREFIX}${hash(cwd)}-${scope ?? "0".repeat(64)}-${nonce}.json`, temporary = join(writing, name);
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] === stamp;
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
- let remaining = DRAIN_BYTES, blocked = 0;
556
- for (const row of rows.slice(0, DRAIN_FILES)) {
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 !== null && record.value.scope !== stamp || record.value.nonce !== match[4]) {
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); }