@mnemonik/shared 7.112.1 → 7.112.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemonik/shared",
3
- "version": "7.112.1",
3
+ "version": "7.112.2",
4
4
  "description": "Shared constants and utilities for Mnemonik packages",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,10 @@
50
50
  */
51
51
 
52
52
  import { execFileSync } from 'node:child_process';
53
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
54
- import { dirname, join } from 'node:path';
53
+ import { createHash, randomUUID } from 'node:crypto';
54
+ import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
55
+ import { dirname, isAbsolute, join } from 'node:path';
56
+ import { performance } from 'node:perf_hooks';
55
57
 
56
58
  /** Bounded so a pathological repo cannot stall the hook's PostToolUse turn. */
57
59
  const GIT_REV_PARSE_TIMEOUT_MS = 400;
@@ -161,6 +163,30 @@ function committedPaths(cwd: string, root: string, from: string | undefined, to:
161
163
  .map((name) => join(root, name));
162
164
  }
163
165
 
166
+ /**
167
+ * Replace `file` whole: write a temporary file beside it, then rename it into
168
+ * place. Hook processes for one session run concurrently (parallel tool calls)
169
+ * and each rewrites the snapshot and the retry ledger; a plain writeFileSync
170
+ * truncates first, so a reader could see an empty or partial file and two
171
+ * writers could interleave into corrupt JSON. A rename is atomic on the same
172
+ * file system: a reader sees the old file or the new one. Concurrent writers
173
+ * still race (the last rename wins), which costs at most a re-sent report.
174
+ */
175
+ function writeFileWhole(file: string, data: string): void {
176
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
177
+ try {
178
+ writeFileSync(temporary, data, { mode: 0o600 });
179
+ renameSync(temporary, file);
180
+ } catch (error) {
181
+ try {
182
+ unlinkSync(temporary);
183
+ } catch {
184
+ // Never written, or already renamed.
185
+ }
186
+ throw error;
187
+ }
188
+ }
189
+
164
190
  function readSnapshot(snapshotFile: string): DirtySnapshot | null {
165
191
  try {
166
192
  const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8')) as
@@ -189,7 +215,7 @@ function writeRoot(snapshotFile: string, root: string, entry: RootSnapshot): voi
189
215
  roots: total > MAX_SNAPSHOT_PATHS ? { [root]: entry } : roots,
190
216
  };
191
217
  mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
192
- writeFileSync(snapshotFile, JSON.stringify(snapshot), { mode: 0o600 });
218
+ writeFileWhole(snapshotFile, JSON.stringify(snapshot));
193
219
  } catch {
194
220
  // Best-effort: a missing snapshot degrades to "report nothing", never to a
195
221
  // wrong report.
@@ -259,3 +285,321 @@ export function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]
259
285
  writeRoot(snapshotFile, root, entry);
260
286
  }
261
287
  }
288
+
289
+ /** Largest file whose content is hashed for an edit fingerprint, in bytes. */
290
+ const EDIT_FINGERPRINT_MAX_BYTES = 8 * 1024 * 1024;
291
+
292
+ /**
293
+ * The fingerprint a host sends with an edit report: the first 32 hex chars of
294
+ * the sha256 of the file's current content, 'absent' when the file does not
295
+ * exist, undefined when it cannot be read, is not a regular file, or exceeds
296
+ * EDIT_FINGERPRINT_MAX_BYTES (the report then carries none and counts as
297
+ * before).
298
+ *
299
+ * Why: a report the server recorded but answered after the host's budget
300
+ * stays unconfirmed here and the next carrier's diff re-sends it. The server
301
+ * compares the fingerprint with the last one it recorded for the path, so the
302
+ * re-send of unchanged content is not counted as a second edit, while any
303
+ * further edit changes the content and is.
304
+ */
305
+ export function editFingerprint(path: string, cwd = ''): string | undefined {
306
+ const absolute = isAbsolute(path) ? path : join(cwd, path);
307
+ try {
308
+ const stat = statSync(absolute);
309
+ if (!stat.isFile() || stat.size > EDIT_FINGERPRINT_MAX_BYTES) return undefined;
310
+ return createHash('sha256').update(readFileSync(absolute)).digest('hex').slice(0, 32);
311
+ } catch (error) {
312
+ return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'absent' : undefined;
313
+ }
314
+ }
315
+
316
+ // ─── Bounded edit-report retry ─────────────────────────────────────────────
317
+ //
318
+ // An edit report the server did not confirm stays pending so a later event
319
+ // can deliver it (CQ-033). Unbounded, that meant every later event re-sent
320
+ // every pending path one by one with a 1.5 s timeout each, forever: a hung or
321
+ // refusing server stalled each hook by ~1.5 s per pending path, past the
322
+ // host's hook timeout (Codex and Grok kill the hook at 5 s; Cursor at 30 s,
323
+ // where it also delayed the catastrophic-command deny check), and permanent
324
+ // refusals were retried for the life of the session. One policy for every
325
+ // host bounds it:
326
+ //
327
+ // - one call per hook event, inside a total time budget well under the
328
+ // shortest host hook timeout; attempts run a few at a time, each capped by
329
+ // the per-attempt timeout and by what is left of the budget;
330
+ // - per path: the first failure retries on the next event, later failures
331
+ // back off exponentially; after EDIT_REPORT_RETRY.maxAttempts attempts, or
332
+ // EDIT_REPORT_RETRY.expiryMs after the first, the path is dropped;
333
+ // - a permanent refusal (4xx other than 408/429, or `{ ok: false }`) drops
334
+ // the path at once; an answer marked `retryable: true` is not a refusal:
335
+ // the server could not check the credential (a database blip) or the hook's
336
+ // own credential could not be used this time (fetchWithHookCredential's 503
337
+ // marker), so it retries like any other transient failure; 429 pauses every
338
+ // report until its Retry-After; an event whose every attempt timed out
339
+ // pauses reports briefly (the server hangs);
340
+ // - a dropped path joins the dirty snapshot exactly as a confirmed one does,
341
+ // so no later diff re-reports it, and the drop is logged at debug level.
342
+ //
343
+ // Pending state lives beside the dirty snapshot (`<snapshotFile>.retries`),
344
+ // per session. The host's caller runs its safety/deny logic before this.
345
+
346
+ export const EDIT_REPORT_RETRY = {
347
+ /** Total time one hook event may spend on edit reports. */
348
+ budgetMs: 1_500,
349
+ /** One report's own ceiling (the shared POST_TOOL_TIMEOUT_MS). */
350
+ attemptTimeoutMs: 1_500,
351
+ /** Reports in flight at once. */
352
+ concurrency: 4,
353
+ maxAttempts: 5,
354
+ /** Backoff after the second failure; doubles per failure after that. */
355
+ backoffBaseMs: 5_000,
356
+ backoffMaxMs: 5 * 60_000,
357
+ /** A path pending longer than this since its first attempt is dropped. */
358
+ expiryMs: 30 * 60_000,
359
+ /** Pause after an event in which every attempt timed out. */
360
+ hungServerPauseMs: 15_000,
361
+ rateLimitDefaultMs: 30_000,
362
+ rateLimitMaxMs: 5 * 60_000,
363
+ /** Oldest pending paths beyond this are dropped. */
364
+ maxPending: 200,
365
+ } as const;
366
+
367
+ /**
368
+ * The edit-report budget a hook event can still afford (CQ-033 review). The
369
+ * event's own work runs after its edit reports and the host kills the hook at
370
+ * its installed timeout, so the reports get only what is left of `deadlineMs`
371
+ * (the host timeout less a safety margin, measured from process start) after
372
+ * the time this process has already spent and `reserveMs`, the worst case of
373
+ * the event's own work still to come; capped at EDIT_REPORT_RETRY.budgetMs.
374
+ * Zero means send nothing now: every path stays pending for a later, lighter
375
+ * event. The per-event sums are pinned by tests/HookEventBudgets.test.ts.
376
+ */
377
+ export function editReportBudgetMs(
378
+ deadlineMs: number,
379
+ reserveMs: number,
380
+ elapsedMs: number = performance.now()
381
+ ): number {
382
+ return Math.max(
383
+ 0,
384
+ Math.min(EDIT_REPORT_RETRY.budgetMs, Math.floor(deadlineMs - reserveMs - elapsedMs))
385
+ );
386
+ }
387
+
388
+ /** What one edit report achieved, as the sending host classifies it. */
389
+ export type EditReportResult =
390
+ | { kind: 'confirmed' }
391
+ | { kind: 'transient'; detail: string; timedOut?: boolean }
392
+ | { kind: 'permanent'; detail: string }
393
+ | { kind: 'rate_limited'; retryAfterMs?: number };
394
+
395
+ /**
396
+ * Classify an HTTP answer to track-ide-edit. `body` is the parsed JSON, if any.
397
+ * `retryable: true` marks a failure that says nothing lasting (see above); an
398
+ * older server never sends it, so its `{ ok: false }` stays permanent.
399
+ */
400
+ export function classifyEditReportResponse(
401
+ status: number,
402
+ body: unknown,
403
+ retryAfter?: string | null
404
+ ): EditReportResult {
405
+ const retryable = (body as { retryable?: unknown } | null | undefined)?.retryable === true;
406
+ if (status >= 200 && status < 300) {
407
+ const ok = (body as { ok?: unknown } | null | undefined)?.ok;
408
+ if (ok === true) return { kind: 'confirmed' };
409
+ if (ok === false && retryable) return { kind: 'transient', detail: 'ok:false retryable' };
410
+ if (ok === false) return { kind: 'permanent', detail: 'ok:false' };
411
+ return { kind: 'transient', detail: 'unreadable response' };
412
+ }
413
+ if (status === 429) {
414
+ const seconds = retryAfter ? Number(retryAfter) : NaN;
415
+ return {
416
+ kind: 'rate_limited',
417
+ ...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}),
418
+ };
419
+ }
420
+ if (status >= 400 && status < 500 && status !== 408 && !retryable) {
421
+ return { kind: 'permanent', detail: `HTTP ${status}` };
422
+ }
423
+ return { kind: 'transient', detail: `HTTP ${status}` };
424
+ }
425
+
426
+ interface PendingReport {
427
+ attempts: number;
428
+ firstAt: number;
429
+ nextAt: number;
430
+ }
431
+
432
+ interface ReportLedger {
433
+ v: 1;
434
+ pending: Record<string, PendingReport>;
435
+ pausedUntil?: number;
436
+ }
437
+
438
+ function ledgerFile(snapshotFile: string): string {
439
+ return `${snapshotFile}.retries`;
440
+ }
441
+
442
+ function readLedger(snapshotFile: string): ReportLedger {
443
+ if (!snapshotFile) return { v: 1, pending: {} };
444
+ try {
445
+ const parsed = JSON.parse(readFileSync(ledgerFile(snapshotFile), 'utf8')) as ReportLedger;
446
+ if (parsed?.v === 1 && typeof parsed.pending === 'object' && parsed.pending !== null) {
447
+ return parsed;
448
+ }
449
+ } catch {
450
+ // Missing or unreadable: start empty.
451
+ }
452
+ return { v: 1, pending: {} };
453
+ }
454
+
455
+ function writeLedger(snapshotFile: string, ledger: ReportLedger): void {
456
+ if (!snapshotFile) return;
457
+ try {
458
+ mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
459
+ writeFileWhole(ledgerFile(snapshotFile), JSON.stringify(ledger));
460
+ } catch {
461
+ // Best-effort: a lost ledger costs at most one extra attempt per path.
462
+ }
463
+ }
464
+
465
+ /** Paths whose report is pending and due now (retry on any hook event). */
466
+ export function pendingEditReports(snapshotFile: string, now = Date.now()): string[] {
467
+ const ledger = readLedger(snapshotFile);
468
+ if ((ledger.pausedUntil ?? 0) > now) return [];
469
+ return Object.entries(ledger.pending)
470
+ .filter(([, entry]) => entry.nextAt <= now)
471
+ .map(([path]) => path);
472
+ }
473
+
474
+ async function attemptReport(
475
+ path: string,
476
+ send: (path: string, signal: AbortSignal) => Promise<EditReportResult>,
477
+ timeoutMs: number
478
+ ): Promise<EditReportResult> {
479
+ const controller = new AbortController();
480
+ let timer: ReturnType<typeof setTimeout> | undefined;
481
+ const timedOut = new Promise<EditReportResult>((resolveTimeout) => {
482
+ timer = setTimeout(() => {
483
+ controller.abort();
484
+ resolveTimeout({ kind: 'transient', detail: 'timeout', timedOut: true });
485
+ }, timeoutMs);
486
+ });
487
+ try {
488
+ return await Promise.race([
489
+ send(path, controller.signal).catch((error: unknown): EditReportResult => ({
490
+ kind: 'transient',
491
+ detail: error instanceof Error ? error.message : String(error),
492
+ timedOut: controller.signal.aborted,
493
+ })),
494
+ timedOut,
495
+ ]);
496
+ } finally {
497
+ clearTimeout(timer);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Report edited paths under the bounded retry policy: this event's new paths
503
+ * plus every pending path now due. Call once per hook event (pass `paths: []`
504
+ * to deliver only pending ones). Confirmed and dropped paths join the dirty
505
+ * snapshot; the rest stay pending for a later event.
506
+ */
507
+ export async function reportEditsWithinBudget(options: {
508
+ snapshotFile: string;
509
+ paths: readonly string[];
510
+ send: (path: string, signal: AbortSignal) => Promise<EditReportResult>;
511
+ budgetMs?: number;
512
+ now?: () => number;
513
+ log?: (message: string) => void;
514
+ }): Promise<{ confirmed: string[]; dropped: string[]; deferred: string[] }> {
515
+ const now = options.now ?? Date.now;
516
+ // Debug level: silent unless MNEMONIK_HOOK_DEBUG is set (hook stderr can
517
+ // reach the host's UI).
518
+ const log =
519
+ options.log ??
520
+ ((message: string) => {
521
+ if (process.env.MNEMONIK_HOOK_DEBUG) process.stderr.write(`${message}\n`);
522
+ });
523
+ const policy = EDIT_REPORT_RETRY;
524
+ const start = now();
525
+ const deadline = start + (options.budgetMs ?? policy.budgetMs);
526
+ const ledger = readLedger(options.snapshotFile);
527
+ const confirmed: string[] = [];
528
+ const dropped: string[] = [];
529
+ const drop = (path: string, why: string): void => {
530
+ delete ledger.pending[path];
531
+ dropped.push(path);
532
+ log(`mnemonik: edit report for ${path} dropped (${why})`);
533
+ };
534
+
535
+ for (const [path, entry] of Object.entries(ledger.pending)) {
536
+ if (start - entry.firstAt > policy.expiryMs) drop(path, 'expired');
537
+ }
538
+ for (const path of options.paths) {
539
+ ledger.pending[path] ??= { attempts: 0, firstAt: start, nextAt: start };
540
+ }
541
+ const due = [...new Set([...options.paths, ...Object.keys(ledger.pending)])].filter(
542
+ (path) => ledger.pending[path] && ledger.pending[path].nextAt <= start
543
+ );
544
+
545
+ let queue = (ledger.pausedUntil ?? 0) > start ? [] : due;
546
+ while (queue.length > 0) {
547
+ const remaining = deadline - now();
548
+ if (remaining <= 0) break;
549
+ const wave = queue.slice(0, policy.concurrency);
550
+ queue = queue.slice(policy.concurrency);
551
+ const timeout = Math.min(policy.attemptTimeoutMs, remaining);
552
+ const results = await Promise.all(
553
+ wave.map(async (path) => [path, await attemptReport(path, options.send, timeout)] as const)
554
+ );
555
+ const at = now();
556
+ let rateLimited = false;
557
+ for (const [path, result] of results) {
558
+ const entry = ledger.pending[path];
559
+ if (!entry) continue;
560
+ if (result.kind === 'confirmed') {
561
+ delete ledger.pending[path];
562
+ confirmed.push(path);
563
+ } else if (result.kind === 'permanent') {
564
+ drop(path, result.detail);
565
+ } else if (result.kind === 'rate_limited') {
566
+ rateLimited = true;
567
+ const pause = Math.min(
568
+ result.retryAfterMs ?? policy.rateLimitDefaultMs,
569
+ policy.rateLimitMaxMs
570
+ );
571
+ ledger.pausedUntil = Math.max(ledger.pausedUntil ?? 0, at + pause);
572
+ entry.nextAt = ledger.pausedUntil;
573
+ } else {
574
+ entry.attempts += 1;
575
+ if (entry.attempts >= policy.maxAttempts) {
576
+ drop(path, `${entry.attempts} attempts, last: ${result.detail}`);
577
+ } else {
578
+ entry.nextAt =
579
+ entry.attempts <= 1
580
+ ? at
581
+ : at +
582
+ Math.min(policy.backoffBaseMs * 2 ** (entry.attempts - 2), policy.backoffMaxMs);
583
+ }
584
+ }
585
+ }
586
+ if (rateLimited) break;
587
+ if (results.every(([, result]) => result.kind === 'transient' && result.timedOut)) {
588
+ ledger.pausedUntil = at + policy.hungServerPauseMs;
589
+ break;
590
+ }
591
+ }
592
+
593
+ const pendingPaths = Object.keys(ledger.pending);
594
+ if (pendingPaths.length > policy.maxPending) {
595
+ pendingPaths
596
+ .sort((a, b) => (ledger.pending[a]?.firstAt ?? 0) - (ledger.pending[b]?.firstAt ?? 0))
597
+ .slice(0, pendingPaths.length - policy.maxPending)
598
+ .forEach((path) => drop(path, 'too many pending reports'));
599
+ }
600
+ if ((ledger.pausedUntil ?? 0) <= now()) delete ledger.pausedUntil;
601
+ addPathsToGitDirtySnapshot(options.snapshotFile, [...confirmed, ...dropped]);
602
+ writeLedger(options.snapshotFile, ledger);
603
+ const deferred = Object.keys(ledger.pending);
604
+ return { confirmed, dropped, deferred };
605
+ }
@@ -1,5 +1,6 @@
1
1
  import { protectWindowsDirectory, type Execute } from './runtimeSigners.js';
2
- import { stderr } from 'node:process';
2
+ import { stderr, stdin } from 'node:process';
3
+ import { HOOK_STDIN_TIMEOUT_MS } from './hookTimeouts.js';
3
4
  import { resolveProjectIdentity, type ProjectIdentityResolution } from './repositoryRoot.js';
4
5
 
5
6
  export interface ProjectIdentity {
@@ -8,18 +9,47 @@ export interface ProjectIdentity {
8
9
  projectRoot: string;
9
10
  }
10
11
 
12
+ /**
13
+ * One resolution per directory per process. A hook process serves one event,
14
+ * and its handlers resolve the same cwd several times in sequence (each
15
+ * request builder, each gate report); every resolution runs `git rev-parse`
16
+ * with a 2 s timeout, so on a slow or hung git the repeats alone could pass
17
+ * the host's 5 s hook timeout. Only hook runtimes call this (short-lived
18
+ * processes); project setup clears it after it writes an identity.
19
+ */
20
+ const identityCache = new Map<string, Promise<ProjectIdentity | null>>();
21
+
22
+ /** Forget cached resolutions (after project setup wrote a `.mnemonik.json`). */
23
+ export function clearProjectIdentityCache(): void {
24
+ identityCache.clear();
25
+ }
26
+
11
27
  export async function findProjectIdentity(
12
28
  startCwd: string,
13
29
  options: { allowNestedInherit?: boolean; maxDepth?: number } = {}
14
30
  ): Promise<ProjectIdentity | null> {
15
31
  if (!startCwd) return null;
16
- const result = await findProjectIdentityDetailed(startCwd, options);
17
- if (result.kind !== 'ok') return null;
18
- return {
19
- projectId: result.identity.projectId,
20
- projectName: result.identity.projectName,
21
- projectRoot: result.root,
22
- };
32
+ const key = JSON.stringify([
33
+ startCwd,
34
+ options.allowNestedInherit ?? null,
35
+ options.maxDepth ?? null,
36
+ ]);
37
+ let pending = identityCache.get(key);
38
+ if (!pending) {
39
+ pending = findProjectIdentityDetailed(startCwd, options).then((result) =>
40
+ result.kind === 'ok'
41
+ ? {
42
+ projectId: result.identity.projectId,
43
+ projectName: result.identity.projectName,
44
+ projectRoot: result.root,
45
+ }
46
+ : null
47
+ );
48
+ identityCache.set(key, pending);
49
+ // A failure is not an answer: the next caller resolves afresh.
50
+ pending.catch(() => identityCache.delete(key));
51
+ }
52
+ return pending;
23
53
  }
24
54
 
25
55
  export async function findProjectIdentityDetailed(
@@ -29,6 +59,64 @@ export async function findProjectIdentityDetailed(
29
59
  return resolveProjectIdentity(startCwd, options);
30
60
  }
31
61
 
62
+ /** Largest hook payload read from stdin; a bigger one fails open. */
63
+ export const HOOK_STDIN_MAX_BYTES = 1_048_576;
64
+
65
+ /**
66
+ * Read a hook's stdin payload, bounded in size and in time. Resolves the text
67
+ * (a leading BOM removed) when the host closes stdin, or when `timeoutMs`
68
+ * passes first: then with whatever arrived, which the caller parses like any
69
+ * other payload (a partial one fails to parse and the hook fails open).
70
+ * Resolves null past `maxBytes` or on a stream error. After the timeout or
71
+ * the cap the stream is destroyed, so an unclosed pipe no longer holds the
72
+ * process open (Grok's hook returns without calling exit).
73
+ */
74
+ export function readHookStdin(
75
+ options: {
76
+ stream?: NodeJS.ReadableStream & { destroy?: () => void };
77
+ timeoutMs?: number;
78
+ maxBytes?: number;
79
+ } = {}
80
+ ): Promise<string | null> {
81
+ const stream = options.stream ?? stdin;
82
+ const maxBytes = options.maxBytes ?? HOOK_STDIN_MAX_BYTES;
83
+ return new Promise((resolve) => {
84
+ const chunks: Buffer[] = [];
85
+ let bytes = 0;
86
+ let settled = false;
87
+ const text = () =>
88
+ Buffer.concat(chunks)
89
+ .toString('utf8')
90
+ .replace(/^\uFEFF/, '');
91
+ const finish = (value: string | null, release: boolean) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimeout(timer);
95
+ stream.removeListener('data', onData);
96
+ stream.removeListener('end', onEnd);
97
+ if (release) {
98
+ stream.pause();
99
+ stream.destroy?.();
100
+ }
101
+ resolve(value);
102
+ };
103
+ const onData = (chunk: Buffer | string) => {
104
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
105
+ bytes += buffer.length;
106
+ if (bytes > maxBytes) return finish(null, true);
107
+ chunks.push(buffer);
108
+ };
109
+ const onEnd = () => finish(text(), false);
110
+ const timer = setTimeout(
111
+ () => finish(text(), true),
112
+ options.timeoutMs ?? HOOK_STDIN_TIMEOUT_MS
113
+ );
114
+ stream.on('data', onData);
115
+ stream.on('end', onEnd);
116
+ stream.on('error', () => finish(null, false));
117
+ });
118
+ }
119
+
32
120
  let mismatchEmitted = false;
33
121
 
34
122
  export async function parseHookResponse<T>(response: Response): Promise<T> {
@@ -47,6 +47,49 @@ export const POST_TOOL_TIMEOUT_MS = 1500;
47
47
  */
48
48
  export const MCP_PRECHECK_TIMEOUT_MS = 1000;
49
49
 
50
+ /**
51
+ * PreCompact snapshot POST budget (Claude Code and Cursor; Grok keeps a pinned
52
+ * copy). Independent of FETCH_TIMEOUT_MS: the snapshot is the one hook call
53
+ * whose server work is a write the compaction depends on.
54
+ */
55
+ export const PRECOMPACT_TIMEOUT_MS = 4000;
56
+
57
+ /**
58
+ * The timeout each host is installed with for every Mnemonik hook event: the
59
+ * host kills the hook at this. The installers write these values (seconds =
60
+ * value / 1000), so a hook event's worst case is checked against the number
61
+ * the host actually enforces (tests/HookEventBudgets.test.ts).
62
+ */
63
+ export const HOST_HOOK_TIMEOUT_MS = {
64
+ claude_code: 5000,
65
+ copilot: 5000,
66
+ codex: 5000,
67
+ grok: 5000,
68
+ cursor: 30_000,
69
+ } as const;
70
+ export type HookHost = keyof typeof HOST_HOOK_TIMEOUT_MS;
71
+
72
+ /**
73
+ * Time every hook event keeps free below its host's timeout: Node start-up,
74
+ * project identity on a healthy git, local file I/O, writing the answer.
75
+ * Reading stdin has its own bound below. An event's summed worst case must
76
+ * fit the rest.
77
+ */
78
+ export const HOOK_TIMEOUT_MARGIN_MS = 500;
79
+
80
+ /**
81
+ * The longest a hook waits for its host to finish writing and close stdin
82
+ * (readHookStdin). Hosts write the payload at spawn and close the pipe; one
83
+ * that never closes it would otherwise hold the hook until the host kills it.
84
+ * On expiry the hook works with what arrived, or fails open.
85
+ */
86
+ export const HOOK_STDIN_TIMEOUT_MS = 500;
87
+
88
+ /** The latest an event's own work may run to, measured from process start. */
89
+ export function hookDeadlineMs(host: HookHost): number {
90
+ return HOST_HOOK_TIMEOUT_MS[host] - HOOK_TIMEOUT_MARGIN_MS - HOOK_STDIN_TIMEOUT_MS;
91
+ }
92
+
50
93
  /**
51
94
  * Spawn an `AbortController` tied to a timeout. Returns the signal plus a
52
95
  * `cleanup` function the caller MUST invoke (in `finally`) to clear the
package/src/index.ts CHANGED
@@ -59,6 +59,12 @@ export {
59
59
  TELEMETRY_TIMEOUT_MS,
60
60
  POST_TOOL_TIMEOUT_MS,
61
61
  MCP_PRECHECK_TIMEOUT_MS,
62
+ PRECOMPACT_TIMEOUT_MS,
63
+ HOST_HOOK_TIMEOUT_MS,
64
+ HOOK_TIMEOUT_MARGIN_MS,
65
+ HOOK_STDIN_TIMEOUT_MS,
66
+ hookDeadlineMs,
67
+ type HookHost,
62
68
  withHookTimeout,
63
69
  } from './hookTimeouts.js';
64
70
  export {
@@ -98,10 +98,18 @@ export async function readIdentityFile(
98
98
  const text = await readFile(path, 'utf8');
99
99
  if (options.selectedRoot) {
100
100
  // A ticked folder with configuration but no identity is a new project.
101
- // Keep invalid identities and non-regular files on the strict refusal path.
101
+ // Keep invalid identities, non-regular files and any file that names a
102
+ // schema other than 1 on the strict refusal path: a later schema may carry
103
+ // its identity under other keys, and setup must not replace it.
102
104
  try {
103
105
  const value: unknown = JSON.parse(text);
104
- if (value && typeof value === 'object' && !Array.isArray(value) && !('projectId' in value))
106
+ if (
107
+ value &&
108
+ typeof value === 'object' &&
109
+ !Array.isArray(value) &&
110
+ !('projectId' in value) &&
111
+ (!('schemaVersion' in value) || value.schemaVersion === 1)
112
+ )
105
113
  return { kind: 'absent' };
106
114
  } catch {
107
115
  /* The strict parser supplies the diagnostic. */