@mnemonik/shared 7.140.2 → 7.140.3

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.
@@ -714,9 +714,12 @@ export const SCANNER_MAX_DEPTH = 10;
714
714
  * project, never also into this one. A file naming the enclosing project
715
715
  * (`enclosingProjectId`, a copy) is the same project, and a
716
716
  * configuration-only file (no `projectId`) names none: neither is a
717
- * boundary. A file that cannot be read as an identity still is;
718
- * - a linked worktree (a `.git` FILE whose git directory has a `commondir`)
719
- * is another checkout of a project, indexed from its main checkout.
717
+ * boundary. A file that cannot be read as an identity still is, and so is
718
+ * a copy in a full clone (its own `.git` DIRECTORY): a second checkout of
719
+ * the project, whose files would otherwise be indexed twice;
720
+ * - a linked worktree (a `.git` FILE whose git directory has a `commondir`,
721
+ * or is missing) is another checkout of a project, indexed from its main
722
+ * checkout.
720
723
  * A nested clone or a submodule without its own `.mnemonik.json` belongs to
721
724
  * this project and is indexed with it (its `.git` directory is ignored by the
722
725
  * built-in rules). The scan ROOT is exempt by construction (callers only test
@@ -735,6 +738,7 @@ export async function isProjectBoundary(
735
738
  if (!identity) return true;
736
739
  if (identity.kind === 'ok') {
737
740
  if (identity.identity.projectId !== enclosingProjectId) return true;
741
+ if ((await lstatWithin(join(dirPath, '.git')))?.isDirectory()) return true;
738
742
  } else if (identity.kind !== 'absent') return true;
739
743
  }
740
744
  return isLinkedWorktreeCheckout(dirPath);
@@ -758,8 +762,10 @@ const lstatWithin = (path: string) =>
758
762
 
759
763
  /**
760
764
  * True when `dirPath` is the top of a linked git worktree: its `.git` is a
761
- * FILE pointing at a git directory that has a `commondir`. A submodule's
762
- * `.git` file points at `.git/modules/<name>`, which has none.
765
+ * FILE pointing at a git directory that has a `commondir`, or at one that no
766
+ * longer exists (a pruned or moved worktree, or one made on another machine):
767
+ * nothing shows it is this project's own. A submodule's `.git` file points at
768
+ * `.git/modules/<name>`, which exists and has no `commondir`.
763
769
  */
764
770
  export async function isLinkedWorktreeCheckout(dirPath: string): Promise<boolean> {
765
771
  const marker = await lstatWithin(join(dirPath, '.git'));
@@ -773,7 +779,9 @@ export async function isLinkedWorktreeCheckout(dirPath: string): Promise<boolean
773
779
  )
774
780
  )?.[1];
775
781
  if (!pointer) return false;
776
- return !!(await lstatWithin(join(resolve(dirPath, pointer), 'commondir')));
782
+ const gitDirectory = resolve(dirPath, pointer);
783
+ if (!(await lstatWithin(gitDirectory))) return true;
784
+ return !!(await lstatWithin(join(gitDirectory, 'commondir')));
777
785
  } catch {
778
786
  return false;
779
787
  }
@@ -255,13 +255,12 @@ export function isBootstrapDigestPayload(text: string): boolean {
255
255
  * The explicit call that re-fetches a bootstrap digest, for the item that
256
256
  * carries one. A shed emission without a route leaves no stub, so a digest
257
257
  * shed for budget once vanished without a trace (2026-10-10); with this route
258
- * the not-reached marker names the call that brings it back. The digest's own
259
- * `cwd=` line supplies the scope.
258
+ * the not-reached marker names the call that brings it back. The call carries
259
+ * no scope: the hook adds the project.
260
260
  */
261
261
  export function projectContextRetrievalRoute(text: string): string | undefined {
262
262
  if (!isBootstrapDigestPayload(text)) return undefined;
263
- const cwd = /^cwd=(.+)$/m.exec(text)?.[1]?.trim();
264
- return cwd ? `session_bootstrap({ cwd: ${JSON.stringify(cwd)} })` : 'session_bootstrap';
263
+ return 'session_bootstrap({})';
265
264
  }
266
265
 
267
266
  /** Fit one rendered envelope while preserving its exact under-budget bytes. */
@@ -62,7 +62,7 @@
62
62
  *
63
63
  * Each report also carries where the edit happened (`editScope`): the git
64
64
  * worktree and repository of the file, or a mark that the file lies outside
65
- * the project's repository (/tmp, another repository, a `.git` directory). The
65
+ * the project (/tmp, another project, a `.git` directory). The
66
66
  * server counts only project edits, and gates a push on the uncheckpointed
67
67
  * files it carries (`describeHandoffs`).
68
68
  */
@@ -70,6 +70,7 @@
70
70
  import { execFileSync } from 'node:child_process';
71
71
  import { createHash, randomUUID } from 'node:crypto';
72
72
  import {
73
+ lstatSync,
73
74
  mkdirSync,
74
75
  readFileSync,
75
76
  realpathSync,
@@ -78,6 +79,7 @@ import {
78
79
  unlinkSync,
79
80
  writeFileSync,
80
81
  } from 'node:fs';
82
+ import { homedir } from 'node:os';
81
83
  import { dirname, isAbsolute, join, resolve } from 'node:path';
82
84
  import { performance } from 'node:perf_hooks';
83
85
 
@@ -462,6 +464,97 @@ function isWithin(path: string, dir: string): boolean {
462
464
  return path === dir || path.startsWith(base);
463
465
  }
464
466
 
467
+ const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
468
+
469
+ /**
470
+ * The `projectId` a folder's own `.mnemonik.json` names: 'absent' when there is
471
+ * no file or it holds configuration only (no `projectId`, schema 1 or none),
472
+ * null when it cannot be read or is not a valid schema 1 identity.
473
+ */
474
+ function identityFileAt(dir: string): string | 'absent' | null {
475
+ const path = join(dir, '.mnemonik.json');
476
+ let text: string;
477
+ try {
478
+ if (!lstatSync(path).isFile()) return null;
479
+ text = readFileSync(path, 'utf8');
480
+ } catch (error) {
481
+ const code = (error as NodeJS.ErrnoException).code;
482
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : null;
483
+ }
484
+ try {
485
+ const value: unknown = JSON.parse(text);
486
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
487
+ const record = value as Record<string, unknown>;
488
+ if (!('projectId' in record)) {
489
+ return !('schemaVersion' in record) || record.schemaVersion === 1 ? 'absent' : null;
490
+ }
491
+ return record.schemaVersion === 1 &&
492
+ typeof record.projectId === 'string' &&
493
+ CANONICAL_UUID.test(record.projectId)
494
+ ? record.projectId
495
+ : null;
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
501
+ /** The main checkout of a linked worktree whose top is `dir`, or null when `dir` is no such top. */
502
+ function linkedWorktreeMainCheckout(dir: string): string | null {
503
+ const dotGit = join(dir, '.git');
504
+ try {
505
+ if (!lstatSync(dotGit).isFile()) return null;
506
+ const gitDir = gitDirFromFile(dotGit, dir);
507
+ if (!gitDir) return null;
508
+ const common = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
509
+ return common ? dirname(canonicalPath(resolve(gitDir, common))) : null;
510
+ } catch {
511
+ return null;
512
+ }
513
+ }
514
+
515
+ const projectIdCache = new Map<string, string | 'none' | null>();
516
+
517
+ /**
518
+ * The Mnemonik project a directory belongs to: the `projectId` of the nearest
519
+ * `.mnemonik.json` at or above it, as the hooks resolve a session's project
520
+ * (repositoryRoot.ts `resolveNearestProjectIdentity`). A git repository is not
521
+ * a project's edge: a nested clone or submodule belongs to the project around
522
+ * it unless it has an identity file of its own. A configuration-only file is
523
+ * passed, the walk stops at the home folder (whose own file counts), and the
524
+ * top of a linked worktree stands for its main checkout (the worktree's own
525
+ * copy of the file does not count). 'none' outside any project; null when the
526
+ * identity cannot be read, which callers treat as unknown. Read from the file
527
+ * system without git, as `gitLocation` is, and with the Node standard library
528
+ * only (this file is copied into grok-hooks).
529
+ */
530
+ function projectIdAt(dir: string): string | 'none' | null {
531
+ const cached = projectIdCache.get(dir);
532
+ if (cached !== undefined) return cached;
533
+ const home = canonicalPath(homedir());
534
+ let result: string | 'none' | null = 'none';
535
+ let current = canonicalPath(dir);
536
+ let inMainCheckout = false;
537
+ for (;;) {
538
+ const main = inMainCheckout ? null : linkedWorktreeMainCheckout(current);
539
+ if (main) {
540
+ current = main;
541
+ inMainCheckout = true;
542
+ continue;
543
+ }
544
+ const identity = identityFileAt(current);
545
+ if (identity !== 'absent') {
546
+ result = identity;
547
+ break;
548
+ }
549
+ if (current === home) break;
550
+ const parent = dirname(current);
551
+ if (parent === current) break;
552
+ current = parent;
553
+ }
554
+ projectIdCache.set(dir, result);
555
+ return result;
556
+ }
557
+
465
558
  /** Where a reported edit happened, as sent to track-ide-edit. */
466
559
  export interface EditScope {
467
560
  /** The git worktree root that holds the file. Absent when unknown or not in git. */
@@ -480,19 +573,31 @@ export interface EditScope {
480
573
  * Where `filePath` lies relative to the project the agent works in.
481
574
  *
482
575
  * `anchorDir` is the project directory: the host's project root when it has
483
- * one (Claude Code's CLAUDE_PROJECT_DIR), else the event's cwd. A file is
484
- * project work when it is in the same git repository as the anchor (any of
485
- * its worktrees), or, when the anchor is not in git, inside the anchor.
486
- * Anything that cannot be determined is reported as unknown (no claim), so an
487
- * unreadable directory never hides an edit.
576
+ * one (Claude Code's CLAUDE_PROJECT_DIR), else the event's cwd. When the
577
+ * anchor is in a Mnemonik project, a file is project work when the nearest
578
+ * `.mnemonik.json` above it names the same project (`projectIdAt`): a nested
579
+ * clone or submodule is in, and a subproject with its own identity file is
580
+ * out even when it shares the anchor's repository. Otherwise a file is project
581
+ * work when it is in the same git repository as the anchor (any of its
582
+ * worktrees), or, when the anchor is not in git, inside the anchor. A path
583
+ * inside `.git` is never project work. Anything that cannot be determined is
584
+ * reported as unknown (no claim), so an unreadable directory never hides an
585
+ * edit.
488
586
  */
489
587
  export function editScope(filePath: string, cwd: string, anchorDir: string = cwd): EditScope {
490
588
  if (!filePath) return {};
491
589
  const absolute = isAbsolute(filePath) ? filePath : join(cwd, filePath);
492
590
  if (/(?:^|\/)\.git(?:\/|$)/.test(absolute)) return { outsideProject: true };
493
- const file = gitLocation(nearestExistingDirectory(dirname(absolute)));
494
- const anchor = anchorDir ? gitLocation(anchorDir) : null;
591
+ const fileDir = nearestExistingDirectory(dirname(absolute));
592
+ const file = gitLocation(fileDir);
495
593
  const editRoot = file && file !== 'none' ? { editRoot: file.root, editRepo: file.commonDir } : {};
594
+ const anchorProject = anchorDir ? projectIdAt(anchorDir) : null;
595
+ if (anchorProject !== null && anchorProject !== 'none') {
596
+ const fileProject = projectIdAt(fileDir);
597
+ if (fileProject === null || fileProject === anchorProject) return editRoot;
598
+ return { outsideProject: true };
599
+ }
600
+ const anchor = gitLocation(anchorDir);
496
601
  if (file === null || anchor === null) return editRoot;
497
602
  if (anchor === 'none') {
498
603
  return isWithin(absolute, anchorDir) ? editRoot : { outsideProject: true };
@@ -63,6 +63,18 @@ export async function findProjectIdentity(startCwd: string): Promise<ProjectIden
63
63
  return pending;
64
64
  }
65
65
 
66
+ /**
67
+ * What a hook tells the server about its working folder: the project's id
68
+ * (the only project identity) and the project's root folder (the root for
69
+ * repo-relative paths). Both are absent outside a project.
70
+ */
71
+ export async function projectScope(
72
+ startCwd: string
73
+ ): Promise<{ projectId?: string; projectRoot?: string }> {
74
+ const identity = await findProjectIdentity(startCwd);
75
+ return identity ? { projectId: identity.projectId, projectRoot: identity.projectRoot } : {};
76
+ }
77
+
66
78
  /** The project a hook's working folder belongs to: the nearest `.mnemonik.json` above it. */
67
79
  export async function findProjectIdentityDetailed(
68
80
  startCwd: string
@@ -128,20 +140,8 @@ export function readHookStdin(
128
140
  });
129
141
  }
130
142
 
131
- let mismatchEmitted = false;
132
-
133
143
  export async function parseHookResponse<T>(response: Response): Promise<T> {
134
- const body = (await response.json()) as T;
135
- const result = body as { ok?: boolean; reason?: string };
136
- if (!mismatchEmitted && result?.ok === false && result.reason === 'identity_mismatch') {
137
- stderr.write(
138
- 'mnemonik-hook: project identity mismatch - .mnemonik.json points to one project, ' +
139
- 'but this directory is registered to a different one on the server. ' +
140
- 'Fix .mnemonik.json (delete it and let session_bootstrap rewrite it) or cd to the correct project root.\n'
141
- );
142
- mismatchEmitted = true;
143
- }
144
- return body;
144
+ return (await response.json()) as T;
145
145
  }
146
146
 
147
147
  /** Native host correlation only; never accept checkpoint/model fields here. */
@@ -156,7 +156,7 @@
156
156
 
157
157
  // Dynamic response orchestration remains authoritative. This universal floor stays
158
158
  // self-scoping when project context was already loaded through another channel.
159
- const INSTRUCTIONS_CONTENT = `Mnemonik provides persistent project memory: decisions, rationale, tasks, and policies across sessions. If no block begins "PROJECT_CONTEXT schemaVersion=", session_bootstrap({ cwd }) loads project context when cwd is the real absolute path of the project root; a placeholder or relative path fails. With that block, context is loaded and no session_bootstrap call is needed. Code and docs show current artifacts, git shows changes, and memory carries the why; memory_search recalls rationale or intent, then Grep or Read verifies current source.
159
+ const INSTRUCTIONS_CONTENT = `Mnemonik provides persistent project memory: decisions, rationale, tasks, and policies across sessions. If no block begins "PROJECT_CONTEXT schemaVersion=", session_bootstrap({}) loads project context; installed hooks supply the project. With that block, context is loaded and no session_bootstrap call is needed. Code and docs show current artifacts, git shows changes, and memory carries the why; memory_search recalls rationale or intent, then Grep or Read verifies current source.
160
160
 
161
161
  What I learn about you belongs in Companion; what I learn about this project belongs here.
162
162
 
@@ -21,7 +21,12 @@ export type Reason =
21
21
  | 'unsigned'
22
22
  | 'permission'
23
23
  | 'acl_unavailable'
24
- | 'lock_held';
24
+ | 'lock_held'
25
+ // The registry answered without the package (404, 429 or 5xx): a fresh release
26
+ // still reaching its CDN, a busy registry, or a pin it does not have.
27
+ | 'registry_unavailable'
28
+ // The registry could not be reached at all: offline, DNS, refused, timed out.
29
+ | 'registry_unreachable';
25
30
  export class RuntimeError extends Error {
26
31
  constructor(public readonly reason: Reason) {
27
32
  super(reason);