@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.
- package/dist/codeScanner.d.ts +10 -5
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +16 -6
- package/dist/codeScanner.js.map +1 -1
- package/dist/contextBudget.d.ts +2 -2
- package/dist/contextBudget.d.ts.map +1 -1
- package/dist/contextBudget.js +3 -4
- package/dist/contextBudget.js.map +1 -1
- package/dist/gitEditDetector.d.ts +11 -6
- package/dist/gitEditDetector.d.ts.map +1 -1
- package/dist/gitEditDetector.js +119 -9
- package/dist/gitEditDetector.js.map +1 -1
- package/dist/hookRuntime.d.ts +9 -0
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +10 -10
- package/dist/hookRuntime.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +1 -1
- package/dist/runtimeReader.d.ts +1 -1
- package/dist/runtimeReader.d.ts.map +1 -1
- package/dist/runtimeReader.js.map +1 -1
- package/package.json +1 -1
- package/src/codeScanner.ts +14 -6
- package/src/contextBudget.ts +3 -4
- package/src/gitEditDetector.ts +113 -8
- package/src/hookRuntime.ts +13 -13
- package/src/instructions.ts +1 -1
- package/src/runtimeReader.ts +6 -1
package/src/codeScanner.ts
CHANGED
|
@@ -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
|
-
*
|
|
719
|
-
*
|
|
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
|
|
762
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/contextBudget.ts
CHANGED
|
@@ -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
|
|
259
|
-
*
|
|
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
|
-
|
|
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. */
|
package/src/gitEditDetector.ts
CHANGED
|
@@ -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
|
|
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.
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
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
|
|
494
|
-
const
|
|
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 };
|
package/src/hookRuntime.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/instructions.ts
CHANGED
|
@@ -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({
|
|
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
|
|
package/src/runtimeReader.ts
CHANGED
|
@@ -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);
|