@mnemonik/shared 7.140.1 → 7.140.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.
Files changed (40) hide show
  1. package/dist/codeScanner.d.ts +41 -1
  2. package/dist/codeScanner.d.ts.map +1 -1
  3. package/dist/codeScanner.js +90 -12
  4. package/dist/codeScanner.js.map +1 -1
  5. package/dist/hookRuntime.d.ts +9 -8
  6. package/dist/hookRuntime.d.ts.map +1 -1
  7. package/dist/hookRuntime.js +21 -10
  8. package/dist/hookRuntime.js.map +1 -1
  9. package/dist/hookTrace.d.ts +149 -0
  10. package/dist/hookTrace.d.ts.map +1 -0
  11. package/dist/hookTrace.js +483 -0
  12. package/dist/hookTrace.js.map +1 -0
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/projectIdentityFile.d.ts +13 -0
  18. package/dist/projectIdentityFile.d.ts.map +1 -1
  19. package/dist/projectIdentityFile.js +31 -18
  20. package/dist/projectIdentityFile.js.map +1 -1
  21. package/dist/protectedPaths.d.ts +6 -0
  22. package/dist/protectedPaths.d.ts.map +1 -1
  23. package/dist/protectedPaths.js +15 -6
  24. package/dist/protectedPaths.js.map +1 -1
  25. package/dist/repositoryRoot.d.ts +20 -8
  26. package/dist/repositoryRoot.d.ts.map +1 -1
  27. package/dist/repositoryRoot.js +167 -79
  28. package/dist/repositoryRoot.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/codeScanner.ts +107 -9
  31. package/src/hookRuntime.ts +26 -14
  32. package/src/hookTrace.ts +588 -0
  33. package/src/index.ts +4 -0
  34. package/src/projectIdentityFile.ts +34 -20
  35. package/src/protectedPaths.ts +18 -7
  36. package/src/repositoryRoot.ts +188 -82
  37. package/test-fixtures/identity/runner.d.mts +1 -1
  38. package/test-fixtures/identity/runner.mjs +299 -25
  39. package/tests/hookTrace.test.ts +350 -0
  40. package/tests/nearestProjectIdentity.test.ts +253 -0
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Hook flight recorder: a bounded, local, append-only record of every hook
3
+ * invocation on this machine, so an agent or support can see what a real
4
+ * editor sent to the hooks and what they answered.
5
+ *
6
+ * One JSON Lines file per host under the Mnemonik state folder
7
+ * (`<state>/hook-trace/<host>.jsonl`, directory 0700, file 0600), rotated to a
8
+ * single `.1` file past {@link HOOK_TRACE_MAX_BYTES}. Each invocation writes
9
+ * up to three lines sharing one run `id`: `start` as soon as the recorder
10
+ * starts (before stdin is read, so a run the editor kills while it is still
11
+ * sending the event leaves a trace), `input` once the event is known, and
12
+ * `end` when the process exits. A run with no `end` is one the editor killed
13
+ * (a signal ends the process without Node's `exit` event); one with only
14
+ * `start` was killed before its event arrived.
15
+ *
16
+ * The record holds identifiers, field names and sizes only. It never holds
17
+ * prompt text, tool input or output bodies, file contents, transcript
18
+ * contents, credentials or the environment: payload values are reduced to
19
+ * their byte size, and the one free-text field (an error message) passes
20
+ * through `scrubSecrets`.
21
+ *
22
+ * Every write is synchronous and short (one line), and every failure is
23
+ * swallowed: the recorder can never throw into a hook or change its output.
24
+ * Being synchronous, a write to a hung network home directory would block the
25
+ * hook. `MNEMONIK_HOOK_TRACE=off` disables it.
26
+ */
27
+ import { randomBytes } from 'node:crypto';
28
+ import {
29
+ closeSync,
30
+ constants,
31
+ fstatSync,
32
+ lstatSync,
33
+ mkdirSync,
34
+ openSync,
35
+ renameSync,
36
+ statSync,
37
+ unlinkSync,
38
+ writeSync,
39
+ } from 'node:fs';
40
+ import { dirname, join } from 'node:path';
41
+ import { mnemonikStateDirectory } from './protectedPaths.js';
42
+ import { ensureWindowsPrivateDirectorySync } from './runtimeSigners.js';
43
+ import { scrubSecrets } from './secretPatterns.js';
44
+
45
+ export type HookTraceHost = 'cursor' | 'claude-code' | 'codex' | 'copilot';
46
+
47
+ /** Past this size the file is renamed to `<host>.jsonl.1` (replacing any older one). */
48
+ export const HOOK_TRACE_MAX_BYTES = 2 * 1024 * 1024;
49
+ export const HOOK_TRACE_ENV = 'MNEMONIK_HOOK_TRACE';
50
+
51
+ const MAX_FIELDS = 64;
52
+ const MAX_CALLS = 64;
53
+ const MAX_ERROR_CHARS = 80;
54
+ const SAFE_ID = /^[A-Za-z0-9@._+:-]{1,128}$/;
55
+ const SAFE_NAME = /^[A-Za-z0-9@._+:/-]{1,120}$/;
56
+ const SAFE_ENUM = /^[A-Za-z_]{1,24}$/;
57
+
58
+ export interface HookTraceCall {
59
+ /** URL path only; id-like segments become `:id`, never a query string. */
60
+ path: string;
61
+ status?: number;
62
+ /** `timeout`, `aborted`, a system code such as `ECONNREFUSED`, or an error name. */
63
+ error?: string;
64
+ /** Still waiting for an answer when the process exited. */
65
+ pending?: true;
66
+ ms: number;
67
+ }
68
+
69
+ export interface HookTraceOutput {
70
+ bytes: number;
71
+ /** Byte size of each output field set; nested host fields as `hookSpecificOutput.<name>`. */
72
+ sizes: Record<string, number>;
73
+ permission?: string;
74
+ decision?: string;
75
+ continue?: boolean;
76
+ }
77
+
78
+ /** Written as the recorder starts, before stdin is read. */
79
+ export interface HookTraceStart {
80
+ v: 1;
81
+ phase: 'start';
82
+ ts: string;
83
+ host: HookTraceHost;
84
+ hookVersion?: string;
85
+ pid: number;
86
+ id: string;
87
+ /** From process start, including Node start-up and module load. */
88
+ processMs: number;
89
+ }
90
+
91
+ /** Written once stdin is read and the event is known. */
92
+ export interface HookTraceInput {
93
+ v: 1;
94
+ phase: 'input';
95
+ ts: string;
96
+ host: HookTraceHost;
97
+ pid: number;
98
+ id: string;
99
+ event: string | null;
100
+ hostVersion?: string;
101
+ sessionId?: string;
102
+ conversationId?: string;
103
+ generationId?: string;
104
+ agentId?: string;
105
+ turnId?: string;
106
+ tool?: string;
107
+ cwd?: string;
108
+ inputBytes: number;
109
+ /** Byte size of each top-level input field's JSON value. Values are never recorded. */
110
+ fields: Record<string, number>;
111
+ /** Time from the recorder starting to the event being known (mostly the stdin read). */
112
+ readMs: number;
113
+ }
114
+
115
+ export interface HookTraceEnd {
116
+ v: 1;
117
+ phase: 'end';
118
+ ts: string;
119
+ host: HookTraceHost;
120
+ pid: number;
121
+ id: string;
122
+ event: string | null;
123
+ projectId?: string;
124
+ output: HookTraceOutput | null;
125
+ calls: HookTraceCall[];
126
+ /** From the recorder starting (after module load) to exit. */
127
+ durationMs: number;
128
+ /** From process start, including Node start-up and module load. */
129
+ processMs: number;
130
+ exitCode: number;
131
+ /** Name or system code of the error the hook failed open on. */
132
+ errorKind?: string;
133
+ /** Its message, scrubbed and cut to 80 characters. */
134
+ error?: string;
135
+ }
136
+
137
+ export type HookTraceRecord = HookTraceStart | HookTraceInput | HookTraceEnd;
138
+
139
+ export function hookTraceEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
140
+ const value = env[HOOK_TRACE_ENV]?.trim().toLowerCase();
141
+ return !value || !['off', '0', 'false', 'no', 'disabled'].includes(value);
142
+ }
143
+
144
+ export function hookTraceDirectory(stateDir: string = mnemonikStateDirectory()): string {
145
+ return join(stateDir, 'hook-trace');
146
+ }
147
+
148
+ export function hookTraceFile(host: HookTraceHost, stateDir?: string): string {
149
+ return join(hookTraceDirectory(stateDir), `${host}.jsonl`);
150
+ }
151
+
152
+ const preparedDirectories = new Set<string>();
153
+
154
+ function ensurePrivateDirectory(directory: string): void {
155
+ if (preparedDirectories.has(directory)) return;
156
+ if (process.platform === 'win32') ensureWindowsPrivateDirectorySync(directory);
157
+ else {
158
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
159
+ const stat = lstatSync(directory);
160
+ if (
161
+ !stat.isDirectory() ||
162
+ stat.isSymbolicLink() ||
163
+ (process.getuid && stat.uid !== process.getuid()) ||
164
+ stat.mode & 0o077
165
+ )
166
+ throw new Error('hook trace directory is not private');
167
+ }
168
+ preparedDirectories.add(directory);
169
+ }
170
+
171
+ function openTrace(file: string): number {
172
+ return openSync(
173
+ file,
174
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0),
175
+ 0o600
176
+ );
177
+ }
178
+
179
+ /** Whether `file` still names the file `fd` has open: no other writer rotated it away. */
180
+ function sameFile(file: string, fd: number): boolean {
181
+ try {
182
+ const opened = fstatSync(fd, { bigint: true });
183
+ const named = statSync(file, { bigint: true });
184
+ return opened.ino === named.ino && opened.dev === named.dev;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ /** A rotation lock older than this was left by a killed writer. */
191
+ const STALE_LOCK_MS = 10_000;
192
+
193
+ function takeLock(lock: string): boolean {
194
+ for (let attempt = 0; attempt < 2; attempt += 1) {
195
+ try {
196
+ closeSync(openSync(lock, 'wx', 0o600));
197
+ return true;
198
+ } catch {
199
+ try {
200
+ if (attempt > 0 || Date.now() - lstatSync(lock).mtimeMs < STALE_LOCK_MS) return false;
201
+ unlinkSync(lock);
202
+ } catch {
203
+ /* Gone already: try once more. */
204
+ }
205
+ }
206
+ }
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Rotate the full file `fd` has open to `.1`. Hooks run concurrently, so
212
+ * rotation is serialised by an exclusive lock file and, while holding it,
213
+ * happens only when the path still names the file `fd` has open: a writer
214
+ * that finds another already rotated (or rotating) appends to the fresh file
215
+ * instead of rotating again and replacing the full file in `.1`.
216
+ */
217
+ function rotate(file: string, fd: number): void {
218
+ const lock = `${file}.lock`;
219
+ if (!takeLock(lock)) return;
220
+ try {
221
+ if (sameFile(file, fd)) renameSync(file, `${file}.1`);
222
+ } finally {
223
+ try {
224
+ unlinkSync(lock);
225
+ } catch {
226
+ /* Already removed as stale: nothing to release. */
227
+ }
228
+ }
229
+ }
230
+
231
+ /** Append one line, rotating first when the file is full. Throws; callers swallow. */
232
+ export function appendHookTraceLine(file: string, record: HookTraceRecord): void {
233
+ ensurePrivateDirectory(dirname(file));
234
+ const line = `${JSON.stringify(record)}\n`;
235
+ let fd = openTrace(file);
236
+ try {
237
+ const stat = fstatSync(fd);
238
+ if (!stat.isFile() || (process.getuid && stat.uid !== process.getuid()))
239
+ throw new Error('hook trace file is not ours');
240
+ if (stat.size > 0 && stat.size + Buffer.byteLength(line) > HOOK_TRACE_MAX_BYTES) {
241
+ try {
242
+ rotate(file, fd);
243
+ } finally {
244
+ closeSync(fd);
245
+ fd = -1;
246
+ }
247
+ fd = openTrace(file);
248
+ }
249
+ writeSync(fd, line);
250
+ } finally {
251
+ if (fd >= 0) closeSync(fd);
252
+ }
253
+ }
254
+
255
+ function byteSize(value: unknown): number {
256
+ if (typeof value === 'string') return Buffer.byteLength(value);
257
+ try {
258
+ return Buffer.byteLength(JSON.stringify(value) ?? '');
259
+ } catch {
260
+ return 0;
261
+ }
262
+ }
263
+
264
+ function safe(value: unknown, pattern: RegExp): string | undefined {
265
+ return typeof value === 'string' && pattern.test(value) ? value : undefined;
266
+ }
267
+
268
+ function sizesOf(object: Record<string, unknown>, prefix = ''): Record<string, number> {
269
+ const sizes: Record<string, number> = {};
270
+ for (const key of Object.keys(object).slice(0, MAX_FIELDS))
271
+ if (SAFE_NAME.test(key)) sizes[prefix + key] = byteSize(object[key]);
272
+ return sizes;
273
+ }
274
+
275
+ const isObject = (value: unknown): value is Record<string, unknown> =>
276
+ !!value && typeof value === 'object' && !Array.isArray(value);
277
+
278
+ /** Field names and sizes, the decision fields' short enum values, never text. */
279
+ export function summarizeHookOutput(text: string): HookTraceOutput {
280
+ const output: HookTraceOutput = { bytes: Buffer.byteLength(text), sizes: {} };
281
+ let value: unknown;
282
+ try {
283
+ value = JSON.parse(text);
284
+ } catch {
285
+ return output;
286
+ }
287
+ if (!isObject(value)) return output;
288
+ const nested = isObject(value.hookSpecificOutput) ? value.hookSpecificOutput : undefined;
289
+ const { hookSpecificOutput: _nested, ...top } = value;
290
+ output.sizes = { ...sizesOf(top), ...(nested ? sizesOf(nested, 'hookSpecificOutput.') : {}) };
291
+ const permission = safe(value.permission ?? nested?.permissionDecision, SAFE_ENUM);
292
+ const decision = safe(value.decision, SAFE_ENUM);
293
+ if (permission) output.permission = permission;
294
+ if (decision) output.decision = decision;
295
+ if (typeof value.continue === 'boolean') output.continue = value.continue;
296
+ return output;
297
+ }
298
+
299
+ function routeOf(input: unknown): string {
300
+ try {
301
+ const href =
302
+ typeof input === 'string'
303
+ ? input
304
+ : input instanceof URL
305
+ ? input.href
306
+ : (input as { url?: string }).url;
307
+ return new URL(String(href)).pathname
308
+ .split('/')
309
+ .map((segment) =>
310
+ (segment.length >= 16 && /\d/.test(segment)) || /[%@]/.test(segment) ? ':id' : segment
311
+ )
312
+ .join('/')
313
+ .slice(0, 200);
314
+ } catch {
315
+ return 'unknown';
316
+ }
317
+ }
318
+
319
+ export function errorKind(error: unknown): string {
320
+ const failure = error as { name?: unknown; code?: unknown; cause?: { code?: unknown } } | null;
321
+ if (failure?.name === 'TimeoutError') return 'timeout';
322
+ if (failure?.name === 'AbortError') return 'aborted';
323
+ const code = failure?.cause?.code ?? failure?.code;
324
+ if (typeof code === 'string' && /^[A-Z0-9_]{2,40}$/.test(code)) return code;
325
+ return safe(failure?.name, /^\w{1,40}$/) ?? 'error';
326
+ }
327
+
328
+ /** The message, whitespace folded, secrets scrubbed, cut to 80 characters. */
329
+ export function scrubHookTraceMessage(error: unknown): string {
330
+ const text = error instanceof Error ? error.message : String(error);
331
+ return scrubSecrets(text.replace(/\s+/g, ' ').trim()).slice(0, MAX_ERROR_CHARS);
332
+ }
333
+
334
+ export interface HookTraceCallToken {
335
+ input: unknown;
336
+ startedAt: number;
337
+ }
338
+
339
+ export interface HookTraceOptions {
340
+ host: HookTraceHost;
341
+ hookVersion?: string;
342
+ env?: NodeJS.ProcessEnv;
343
+ stateDir?: string;
344
+ }
345
+
346
+ export class HookTrace {
347
+ private readonly id = randomBytes(8).toString('hex');
348
+ private readonly startedAt = performance.now();
349
+ private opened = false;
350
+ private started = false;
351
+ private ended = false;
352
+ private event: string | null = null;
353
+ private outputSummary: HookTraceOutput | null = null;
354
+ private error?: string;
355
+ private errorKind?: string;
356
+ private projectId?: string;
357
+ private readonly calls: HookTraceCall[] = [];
358
+ private readonly inFlight = new Set<HookTraceCallToken>();
359
+
360
+ constructor(
361
+ private readonly host: HookTraceHost,
362
+ private readonly file: string | undefined,
363
+ private readonly hookVersion?: string
364
+ ) {}
365
+
366
+ get enabled(): boolean {
367
+ return this.file !== undefined;
368
+ }
369
+
370
+ private write(record: HookTraceRecord): void {
371
+ if (!this.file) return;
372
+ try {
373
+ appendHookTraceLine(this.file, record);
374
+ } catch {
375
+ /* A diagnostic record never costs the hook anything. */
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Write the `start` line: pid, run id and time since process start, nothing
381
+ * from the editor. Runs before stdin is read, so a run killed while the
382
+ * editor is still sending its event leaves this line.
383
+ */
384
+ open(): void {
385
+ if (!this.file || this.opened) return;
386
+ this.opened = true;
387
+ this.write({
388
+ v: 1,
389
+ phase: 'start',
390
+ ts: new Date().toISOString(),
391
+ host: this.host,
392
+ ...(this.hookVersion ? { hookVersion: this.hookVersion } : {}),
393
+ pid: process.pid,
394
+ id: this.id,
395
+ processMs: Math.round(process.uptime() * 1000),
396
+ });
397
+ }
398
+
399
+ /** Write the `input` line. `input` is the parsed payload, or null when none parsed. */
400
+ begin(input: unknown, event?: string | null): void {
401
+ if (!this.file || this.started) return;
402
+ this.open();
403
+ this.started = true;
404
+ try {
405
+ const payload = isObject(input) ? input : {};
406
+ this.event =
407
+ safe(event, SAFE_NAME) ??
408
+ safe(payload.hook_event_name, SAFE_NAME) ??
409
+ safe(payload.hookEventName, SAFE_NAME) ??
410
+ null;
411
+ const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
412
+ const cwd =
413
+ typeof payload.cwd === 'string' && payload.cwd
414
+ ? payload.cwd
415
+ : typeof roots[0] === 'string'
416
+ ? roots[0]
417
+ : process.cwd();
418
+ const record: HookTraceInput = {
419
+ v: 1,
420
+ phase: 'input',
421
+ ts: new Date().toISOString(),
422
+ host: this.host,
423
+ pid: process.pid,
424
+ id: this.id,
425
+ event: this.event,
426
+ inputBytes: isObject(input) ? byteSize(input) : 0,
427
+ fields: sizesOf(payload),
428
+ readMs: Math.round(performance.now() - this.startedAt),
429
+ };
430
+ const hostVersion = safe(payload.cursor_version ?? payload.version, /^[\w.+-]{1,40}$/);
431
+ if (hostVersion) record.hostVersion = hostVersion;
432
+ const ids: Array<[keyof HookTraceInput, string]> = [
433
+ ['sessionId', 'session_id'],
434
+ ['conversationId', 'conversation_id'],
435
+ ['generationId', 'generation_id'],
436
+ ['agentId', 'agent_id'],
437
+ ['agentId', 'subagent_id'],
438
+ ['turnId', 'turn_id'],
439
+ ];
440
+ for (const [name, key] of ids) {
441
+ const id = safe(payload[key], SAFE_ID);
442
+ if (id && record[name] === undefined)
443
+ (record as unknown as Record<string, string>)[name] = id;
444
+ }
445
+ const tool = safe(payload.tool_name, SAFE_NAME);
446
+ if (tool) record.tool = tool;
447
+ if (cwd.length <= 1024) record.cwd = cwd;
448
+ this.write(record);
449
+ } catch {
450
+ /* Never into the hook. */
451
+ }
452
+ }
453
+
454
+ /** The exact text the hook wrote to stdout (or nothing). */
455
+ output(text: string | null | undefined): void {
456
+ if (!this.file || text === null || text === undefined) return;
457
+ try {
458
+ this.outputSummary = summarizeHookOutput(text);
459
+ } catch {
460
+ /* Never into the hook. */
461
+ }
462
+ }
463
+
464
+ /** The error the hook failed open on. */
465
+ fail(error: unknown): void {
466
+ if (!this.file) return;
467
+ try {
468
+ this.errorKind = errorKind(error);
469
+ this.error = scrubHookTraceMessage(error);
470
+ } catch {
471
+ this.errorKind = 'error';
472
+ }
473
+ }
474
+
475
+ project(projectId: string | undefined): void {
476
+ if (projectId && !this.projectId && SAFE_ID.test(projectId)) this.projectId = projectId;
477
+ }
478
+
479
+ /** A call has gone out; {@link settle} records it, {@link finish} records it as pending. */
480
+ callStarted(input: unknown): HookTraceCallToken {
481
+ const token = { input, startedAt: performance.now() };
482
+ if (this.file) this.inFlight.add(token);
483
+ return token;
484
+ }
485
+
486
+ settle(token: HookTraceCallToken, status?: number, error?: unknown): void {
487
+ this.inFlight.delete(token);
488
+ this.call(token.input, token.startedAt, status, error);
489
+ }
490
+
491
+ call(input: unknown, startedAt: number, status?: number, error?: unknown): void {
492
+ if (!this.file || this.calls.length >= MAX_CALLS) return;
493
+ try {
494
+ this.calls.push({
495
+ path: routeOf(input),
496
+ ...(status !== undefined ? { status } : { error: errorKind(error) }),
497
+ ms: Math.round(performance.now() - startedAt),
498
+ });
499
+ } catch {
500
+ /* Never into the hook. */
501
+ }
502
+ }
503
+
504
+ /** Write the `end` line once. Runs from the process `exit` event. */
505
+ finish(exitCode = 0): void {
506
+ if (!this.file || this.ended) return;
507
+ this.open();
508
+ this.ended = true;
509
+ for (const { input, startedAt } of this.inFlight) {
510
+ if (this.calls.length >= MAX_CALLS) break;
511
+ try {
512
+ this.calls.push({
513
+ path: routeOf(input),
514
+ pending: true,
515
+ ms: Math.round(performance.now() - startedAt),
516
+ });
517
+ } catch {
518
+ /* Never into the hook. */
519
+ }
520
+ }
521
+ this.inFlight.clear();
522
+ this.write({
523
+ v: 1,
524
+ phase: 'end',
525
+ ts: new Date().toISOString(),
526
+ host: this.host,
527
+ pid: process.pid,
528
+ id: this.id,
529
+ event: this.event,
530
+ ...(this.projectId ? { projectId: this.projectId } : {}),
531
+ output: this.outputSummary,
532
+ calls: this.calls,
533
+ durationMs: Math.round(performance.now() - this.startedAt),
534
+ processMs: Math.round(process.uptime() * 1000),
535
+ exitCode,
536
+ ...(this.errorKind ? { errorKind: this.errorKind } : {}),
537
+ ...(this.error ? { error: this.error } : {}),
538
+ });
539
+ }
540
+ }
541
+
542
+ let active: HookTrace | undefined;
543
+
544
+ /** Resolved project ids reach the trace from the shared identity cache. */
545
+ export function noteHookTraceProject(projectId: string | undefined): void {
546
+ active?.project(projectId);
547
+ }
548
+
549
+ /**
550
+ * Start this process's recorder: one per hook process. Writes the `start`
551
+ * line at once (so call it before reading stdin), times every `fetch` the
552
+ * process makes (path, status or error kind, duration; `pending` when still
553
+ * waiting at exit) and writes the `end` line from the process `exit` event,
554
+ * so every exit path is covered.
555
+ */
556
+ export function startHookTrace(options: HookTraceOptions): HookTrace {
557
+ if (active) return active;
558
+ let file: string | undefined;
559
+ try {
560
+ if (hookTraceEnabled(options.env))
561
+ file = hookTraceFile(
562
+ options.host,
563
+ options.stateDir ?? mnemonikStateDirectory(process.platform, options.env ?? process.env)
564
+ );
565
+ } catch {
566
+ file = undefined;
567
+ }
568
+ const trace = new HookTrace(options.host, file, options.hookVersion);
569
+ active = trace;
570
+ if (!file) return trace;
571
+ const original = globalThis.fetch;
572
+ if (typeof original === 'function') {
573
+ globalThis.fetch = async (input, init) => {
574
+ const token = trace.callStarted(input);
575
+ try {
576
+ const response = await original.call(globalThis, input, init);
577
+ trace.settle(token, response.status);
578
+ return response;
579
+ } catch (error) {
580
+ trace.settle(token, undefined, error);
581
+ throw error;
582
+ }
583
+ };
584
+ }
585
+ process.once('exit', (code) => trace.finish(typeof code === 'number' ? code : 0));
586
+ trace.open();
587
+ return trace;
588
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,10 @@ export {
18
18
  languageForExtension,
19
19
  makeIgnoreMatcher,
20
20
  isGitBoundary,
21
+ isProjectBoundary,
22
+ rootProjectId,
23
+ SCANNER_MAX_DEPTH,
24
+ isLinkedWorktreeCheckout,
21
25
  FIXTURE_PATH_RE,
22
26
  isFixturePath,
23
27
  isSecretFile,
@@ -86,9 +86,35 @@ export function parseIdentityFile(text: string): Exclude<IdentityFileResult, { k
86
86
  };
87
87
  }
88
88
 
89
+ /**
90
+ * A `.mnemonik.json` that holds configuration but no identity: a JSON object
91
+ * with no `projectId` that names schema 1 or no schema. Invalid identities,
92
+ * and any file naming another schema (which may carry its identity under other
93
+ * keys), are not.
94
+ */
95
+ export function isConfigurationOnly(text: string): boolean {
96
+ try {
97
+ const value: unknown = JSON.parse(text);
98
+ return (
99
+ !!value &&
100
+ typeof value === 'object' &&
101
+ !Array.isArray(value) &&
102
+ !('projectId' in value) &&
103
+ (!('schemaVersion' in value) || value.schemaVersion === 1)
104
+ );
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Read a folder's identity file. With `configurationOnlyAbsent` (and with
112
+ * `selectedRoot`, folders ticked during install), a configuration-only file is
113
+ * no identity: the folder is not a project of its own.
114
+ */
89
115
  export async function readIdentityFile(
90
116
  dir: string,
91
- options: { selectedRoot?: boolean } = {}
117
+ options: { selectedRoot?: boolean; configurationOnlyAbsent?: boolean } = {}
92
118
  ): Promise<IdentityFileResult> {
93
119
  const path = join(dir, '.mnemonik.json');
94
120
  try {
@@ -96,25 +122,13 @@ export async function readIdentityFile(
96
122
  if (stat.isSymbolicLink()) return { kind: 'malformed', detail: 'symlink' };
97
123
  if (!stat.isFile()) return { kind: 'malformed', detail: 'identity path is not a regular file' };
98
124
  const text = await readFile(path, 'utf8');
99
- if (options.selectedRoot) {
100
- // A ticked folder with configuration but no identity is a new project.
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.
104
- try {
105
- const value: unknown = JSON.parse(text);
106
- if (
107
- value &&
108
- typeof value === 'object' &&
109
- !Array.isArray(value) &&
110
- !('projectId' in value) &&
111
- (!('schemaVersion' in value) || value.schemaVersion === 1)
112
- )
113
- return { kind: 'absent' };
114
- } catch {
115
- /* The strict parser supplies the diagnostic. */
116
- }
117
- }
125
+ // A ticked folder with configuration but no identity is a new project, and
126
+ // a configuration-only file met walking up is not a project boundary. Keep
127
+ // invalid identities, non-regular files and any file that names a schema
128
+ // other than 1 on the strict refusal path: a later schema may carry its
129
+ // identity under other keys, and setup must not replace it.
130
+ if ((options.selectedRoot || options.configurationOnlyAbsent) && isConfigurationOnly(text))
131
+ return { kind: 'absent' };
118
132
  return parseIdentityFile(text);
119
133
  } catch (error) {
120
134
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' };
@@ -7,6 +7,23 @@ function pathsFor(platform: Platform): PlatformPath {
7
7
  return platform === 'win32' ? win32 : posix;
8
8
  }
9
9
 
10
+ /**
11
+ * Mnemonik's local state folder: the same answer as `stateDirectory` in
12
+ * @mnemonik/local-setup, which depends on this package and so cannot be
13
+ * imported here.
14
+ */
15
+ export function mnemonikStateDirectory(
16
+ platform: Platform = process.platform,
17
+ env: NodeJS.ProcessEnv = process.env,
18
+ home: string = homedir()
19
+ ): string {
20
+ if (env.MNEMONIK_STATE_DIR) return env.MNEMONIK_STATE_DIR;
21
+ if (platform === 'win32')
22
+ return win32.join(env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local'), 'Mnemonik');
23
+ if (platform === 'darwin') return posix.join(home, 'Library', 'Application Support', 'Mnemonik');
24
+ return posix.join(env.XDG_STATE_HOME || posix.join(home, '.local', 'state'), 'mnemonik');
25
+ }
26
+
10
27
  /** Local state and credential locations that Mnemonik must never scan. */
11
28
  export function protectedLocalPaths(
12
29
  platform: Platform = process.platform,
@@ -16,13 +33,7 @@ export function protectedLocalPaths(
16
33
  const path = pathsFor(platform);
17
34
  const localAppData = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local');
18
35
  const appData = env.APPDATA || win32.join(home, 'AppData', 'Roaming');
19
- const state =
20
- env.MNEMONIK_STATE_DIR ||
21
- (platform === 'win32'
22
- ? win32.join(localAppData, 'Mnemonik')
23
- : platform === 'darwin'
24
- ? posix.join(home, 'Library', 'Application Support', 'Mnemonik')
25
- : posix.join(env.XDG_STATE_HOME || posix.join(home, '.local', 'state'), 'mnemonik'));
36
+ const state = mnemonikStateDirectory(platform, env, home);
26
37
  const common = [
27
38
  state,
28
39
  path.join(home, '.mnemonik'),