@mnemonik/shared 7.97.2 → 7.98.1

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 (130) hide show
  1. package/dist/ambientPointer.d.ts.map +1 -1
  2. package/dist/ambientPointer.js +2 -1
  3. package/dist/ambientPointer.js.map +1 -1
  4. package/dist/apiOrigin.d.ts +3 -0
  5. package/dist/apiOrigin.d.ts.map +1 -0
  6. package/dist/apiOrigin.js +5 -0
  7. package/dist/apiOrigin.js.map +1 -0
  8. package/dist/ast/astChunker.d.ts +12 -12
  9. package/dist/ast/astChunker.js +32 -32
  10. package/dist/ast/grammars.d.ts +9 -9
  11. package/dist/ast/grammars.js +15 -15
  12. package/dist/codeScanner.d.ts +56 -43
  13. package/dist/codeScanner.d.ts.map +1 -1
  14. package/dist/codeScanner.js +262 -143
  15. package/dist/codeScanner.js.map +1 -1
  16. package/dist/contextBudget.js +2 -2
  17. package/dist/contextBudget.js.map +1 -1
  18. package/dist/gitEditDetector.d.ts +6 -6
  19. package/dist/gitEditDetector.js +7 -7
  20. package/dist/hookRuntime.d.ts +35 -2
  21. package/dist/hookRuntime.d.ts.map +1 -1
  22. package/dist/hookRuntime.js +127 -33
  23. package/dist/hookRuntime.js.map +1 -1
  24. package/dist/hookTimeouts.d.ts +6 -6
  25. package/dist/hookTimeouts.js +6 -6
  26. package/dist/hostAdapter.d.ts +135 -0
  27. package/dist/hostAdapter.d.ts.map +1 -0
  28. package/dist/hostAdapter.js +323 -0
  29. package/dist/hostAdapter.js.map +1 -0
  30. package/dist/hostBinary.d.ts +29 -0
  31. package/dist/hostBinary.d.ts.map +1 -0
  32. package/dist/hostBinary.js +179 -0
  33. package/dist/hostBinary.js.map +1 -0
  34. package/dist/index.d.ts +11 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/instructions.d.ts +26 -26
  39. package/dist/instructions.js +26 -26
  40. package/dist/jitDeliveryAck.d.ts.map +1 -1
  41. package/dist/jitDeliveryAck.js +2 -0
  42. package/dist/jitDeliveryAck.js.map +1 -1
  43. package/dist/projectIdentityFile.d.ts +25 -0
  44. package/dist/projectIdentityFile.d.ts.map +1 -0
  45. package/dist/projectIdentityFile.js +87 -0
  46. package/dist/projectIdentityFile.js.map +1 -0
  47. package/dist/projectSetupHandoff.d.ts +24 -0
  48. package/dist/projectSetupHandoff.d.ts.map +1 -0
  49. package/dist/projectSetupHandoff.js +249 -0
  50. package/dist/projectSetupHandoff.js.map +1 -0
  51. package/dist/protectedPaths.d.ts +9 -0
  52. package/dist/protectedPaths.d.ts.map +1 -0
  53. package/dist/protectedPaths.js +66 -0
  54. package/dist/protectedPaths.js.map +1 -0
  55. package/dist/readiness.d.ts +143 -0
  56. package/dist/readiness.d.ts.map +1 -0
  57. package/dist/readiness.js +304 -0
  58. package/dist/readiness.js.map +1 -0
  59. package/dist/repositoryFingerprint.d.ts +50 -0
  60. package/dist/repositoryFingerprint.d.ts.map +1 -0
  61. package/dist/repositoryFingerprint.js +160 -0
  62. package/dist/repositoryFingerprint.js.map +1 -0
  63. package/dist/repositoryRoot.d.ts +56 -0
  64. package/dist/repositoryRoot.d.ts.map +1 -0
  65. package/dist/repositoryRoot.js +226 -0
  66. package/dist/repositoryRoot.js.map +1 -0
  67. package/dist/runtimeReader.d.ts +102 -0
  68. package/dist/runtimeReader.d.ts.map +1 -0
  69. package/dist/runtimeReader.js +408 -0
  70. package/dist/runtimeReader.js.map +1 -0
  71. package/dist/runtimeSigners.d.ts +37 -0
  72. package/dist/runtimeSigners.d.ts.map +1 -0
  73. package/dist/runtimeSigners.js +507 -0
  74. package/dist/runtimeSigners.js.map +1 -0
  75. package/dist/scannerSupervisor.d.ts +35 -0
  76. package/dist/scannerSupervisor.d.ts.map +1 -0
  77. package/dist/scannerSupervisor.js +2 -0
  78. package/dist/scannerSupervisor.js.map +1 -0
  79. package/dist/secretPatterns.d.ts +9 -9
  80. package/dist/secretPatterns.d.ts.map +1 -1
  81. package/dist/secretPatterns.js +27 -25
  82. package/dist/secretPatterns.js.map +1 -1
  83. package/dist/testing/hostAdapterConformance.d.ts +17 -0
  84. package/dist/testing/hostAdapterConformance.d.ts.map +1 -0
  85. package/dist/testing/hostAdapterConformance.js +477 -0
  86. package/dist/testing/hostAdapterConformance.js.map +1 -0
  87. package/dist/usageGuide.d.ts +1 -1
  88. package/dist/usageGuide.d.ts.map +1 -1
  89. package/dist/usageGuide.js +4 -4
  90. package/package.json +7 -2
  91. package/queries/bash.tags.scm +1 -1
  92. package/queries/groovy.tags.scm +1 -1
  93. package/scripts/vendor-grammars.ts +13 -13
  94. package/src/ambientPointer.ts +2 -1
  95. package/src/apiOrigin.ts +4 -0
  96. package/src/ast/astChunker.ts +39 -39
  97. package/src/ast/grammars.ts +16 -16
  98. package/src/codeScanner.ts +274 -149
  99. package/src/contextBudget.ts +2 -2
  100. package/src/gitEditDetector.ts +7 -7
  101. package/src/hookRuntime.ts +162 -34
  102. package/src/hookTimeouts.ts +6 -6
  103. package/src/hostAdapter.ts +468 -0
  104. package/src/hostBinary.ts +205 -0
  105. package/src/index.ts +20 -0
  106. package/src/instructions.ts +26 -26
  107. package/src/jitDeliveryAck.ts +2 -0
  108. package/src/projectIdentityFile.ts +100 -0
  109. package/src/projectSetupHandoff.ts +275 -0
  110. package/src/protectedPaths.ts +88 -0
  111. package/src/readiness.ts +516 -0
  112. package/src/repositoryFingerprint.ts +199 -0
  113. package/src/repositoryRoot.ts +261 -0
  114. package/src/runtimeReader.ts +495 -0
  115. package/src/runtimeSigners.ts +577 -0
  116. package/src/scannerSupervisor.ts +26 -0
  117. package/src/secretPatterns.ts +27 -25
  118. package/src/testing/hostAdapterConformance.ts +538 -0
  119. package/src/usageGuide.ts +4 -4
  120. package/test-fixtures/identity/runner.d.mts +12 -0
  121. package/test-fixtures/identity/runner.mjs +282 -0
  122. package/tests/fixtures/repository-fingerprint-v1.golden.json +834 -0
  123. package/tests/projectIdentity.test.ts +56 -0
  124. package/tests/readiness.test.ts +158 -0
  125. package/tests/repositoryFingerprint.test.ts +26 -0
  126. package/tsconfig.test.json +9 -0
  127. package/wasm/bash.tags.scm +1 -1
  128. package/wasm/groovy.tags.scm +1 -1
  129. package/wasm/javascript.tags.scm +2 -2
  130. package/wasm/solidity.tags.scm +1 -1
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export { USAGE_GUIDE } from './usageGuide.js';
10
10
  export {
11
11
  CodeScanner,
12
12
  MAX_SCANNED_FILE_BYTES,
13
+ MAX_PUSH_CHUNK_CONTENT_LENGTH,
13
14
  AUTHORITY_FILE_MATCHERS,
14
15
  BUILT_IN_IGNORE_DIRS,
15
16
  DEFAULT_INCLUDE_EXTENSIONS,
@@ -72,3 +73,22 @@ export {
72
73
  } from './jitDeliveryAck.js';
73
74
  export * from './settingsIo.js';
74
75
  export * from './claudeProxySettings.js';
76
+ export * from './repositoryFingerprint.js';
77
+ export * from './projectIdentityFile.js';
78
+ export * from './repositoryRoot.js';
79
+ export * from './protectedPaths.js';
80
+ export * from './readiness.js';
81
+
82
+ export * from './hostAdapter.js';
83
+ export * from './hostBinary.js';
84
+
85
+ export type {
86
+ ServiceDefinition,
87
+ SupervisorStatus,
88
+ ServiceOperation,
89
+ ServiceResult,
90
+ } from './scannerSupervisor.js';
91
+
92
+ export { apiOrigin } from './apiOrigin.js';
93
+
94
+ export { WINDOWS_SERVICE_BUDGET_MS } from './scannerSupervisor.js';
@@ -7,15 +7,15 @@
7
7
  * Version: 2.110
8
8
  * Updated: 2026-08-27
9
9
  *
10
- * v2.110 — Reordered and compressed the universal instructions to fit the
10
+ * v2.110 - Reordered and compressed the universal instructions to fit the
11
11
  * 2,048-character transport limit. The first 512 characters now carry
12
12
  * the server purpose, self-scoping bootstrap condition, required cwd,
13
13
  * and memory-as-augmentation stance. The retrieval, task pagination,
14
14
  * and tool-floor capabilities remain in a shorter factual form.
15
15
  *
16
- * v2.109 — Compressed the v2.108 memory-search clause. It restated, verbatim,
16
+ * v2.109 - Compressed the v2.108 memory-search clause. It restated, verbatim,
17
17
  * the same search guidance carried by SESSION_OPENER_INSTRUCTION
18
- * (bootstrapDigestRender.ts) — and both co-occur in the same agent
18
+ * (bootstrapDigestRender.ts) - and both co-occur in the same agent
19
19
  * session as the bootstrap digest's capabilities.search block, which
20
20
  * already states the memory_search/code_search call signatures in
21
21
  * full. Kept the essential behavior (memory holds the why; search it
@@ -23,11 +23,11 @@
23
23
  * with Grep/Read; reach for code_search when concepts don't share
24
24
  * the user's words), dropped the restated call shapes, and pointed at
25
25
  * the session context's capabilities block for the exact signatures
26
- * once it loads — this text fires at `initialize`, before any
26
+ * once it loads - this text fires at `initialize`, before any
27
27
  * digest exists, so it cannot point "above" the way the trimmed
28
28
  * SESSION_OPENER clause does. recall-first-class plan, Task A3
29
29
  * rider (reviewer-flagged: character-for-character duplicate).
30
- * v2.108 — Added front-loaded education that persistent project memory is part
30
+ * v2.108 - Added front-loaded education that persistent project memory is part
31
31
  * of the agent's working knowledge. A live Config-B fixture rejected
32
32
  * the first generic invitation: the agent searched code, docs, and git
33
33
  * history, then called the rationale undocumented without searching
@@ -38,7 +38,7 @@
38
38
  * wording stays a compact startup floor, not a token-heavy tutorial or
39
39
  * recurring nudge.
40
40
  *
41
- * v2.106 — Added a static "Which tool for what" floor map. Rides the already-
41
+ * v2.106 - Added a static "Which tool for what" floor map. Rides the already-
42
42
  * cached instructions at near-zero cost and gives every agent a baseline
43
43
  * "which tool for what" for the under-surfaced tools (memory_add,
44
44
  * memory_state, memory_info, memory_links, assist, search_summaries,
@@ -46,55 +46,55 @@
46
46
  * nudges; memory_info in particular has no reactive trigger, so the floor
47
47
  * is its only education.
48
48
  *
49
- * v2.105 — Bootstrap's agent-facing label is generic project context. Use the
49
+ * v2.105 - Bootstrap's agent-facing label is generic project context. Use the
50
50
  * canonical text body's stable `PROJECT_CONTEXT schemaVersion=` marker
51
51
  * for the self-scoping check instead of teaching agents to call the
52
52
  * user's project "Mnemonik".
53
53
  *
54
- * v2.104 — Disambiguated persistent project task memories from Claude Code's
54
+ * v2.104 - Disambiguated persistent project task memories from Claude Code's
55
55
  * host-local TaskList and made completeness proactive. A request for a
56
56
  * complete/all list must build cursor continuation into the first Code
57
57
  * Mode program; guidance returned after an incomplete outer call is too
58
58
  * late to preserve the one-round-trip workflow.
59
59
  *
60
- * v2.103 — Added the compact Discover → Select → Hydrate interpretation
60
+ * v2.103 - Added the compact Discover -> Select -> Hydrate interpretation
61
61
  * fallback for Config-A. Dynamic response orchestration remains
62
62
  * authoritative; this sentence only teaches an un-orchestrated client
63
63
  * how to act on retrieval envelopes without walking full bodies.
64
64
  *
65
- * v2.102 — Strengthened the self-bootstrap trigger to a compelling imperative.
65
+ * v2.102 - Strengthened the self-bootstrap trigger to a compelling imperative.
66
66
  * The v2.101 phrasing ("At the start of a session, unless ... call ...")
67
67
  * was too soft; agents on a blind first turn skipped it. Real-world hole
68
68
  * it must cover: Cursor's FIRST chat after an IDE open fires no
69
69
  * sessionStart (confirmed by hook-stdin capture), and beforeSubmitPrompt
70
70
  * has NO model-facing output field (only user_message, shown to the
71
- * human) — so neither the silent digest nor the hook's
71
+ * human) - so neither the silent digest nor the hook's
72
72
  * session_bootstrap_required directive reaches the model. The MCP
73
73
  * instruction is the ONLY model-facing channel left for that turn, so it
74
74
  * now leads with the blind-state check + a hard "FIRST action MUST be
75
75
  * session_bootstrap, before you reply" and an explicit cwd-REQUIRED
76
76
  * clause. Still self-scoping: skips when the "Mnemonik project context"
77
77
  * block is already present (working hook/proxy delivery), so config-B/C
78
- * with live delivery don't double-call. cwd stays host-agnostic — the
78
+ * with live delivery don't double-call. cwd stays host-agnostic - the
79
79
  * agent passes the project root it is working in (Cursor exposes the
80
80
  * working dir to HOOKS via the stdin `workspace_roots[0]`, which cwdOf
81
81
  * already reads; it is NOT an agent-readable env var, so the instruction
82
82
  * must not name one).
83
83
  *
84
- * v2.101 — Stripped to a single instruction: the session_bootstrap trigger.
84
+ * v2.101 - Stripped to a single instruction: the session_bootstrap trigger.
85
85
  * MCP instructions are set at Server construction (initialize), before
86
- * the server knows the client, so they cannot be scoped per-IDE — and
86
+ * the server knows the client, so they cannot be scoped per-IDE - and
87
87
  * IDE != config (a Claude Code user may be config-B or -C). The content
88
88
  * is therefore universal and SELF-SCOPING: it conditions on whether a
89
89
  * "Mnemonik project context" block is already present (config-B/C with
90
- * working delivery → skip; config-A and any host where hook/proxy
91
- * delivery missed → call). Re-enabled in production
90
+ * working delivery -> skip; config-A and any host where hook/proxy
91
+ * delivery missed -> call). Re-enabled in production
92
92
  * (MNEMONIK_INSTRUCTIONS_ENABLED=true in both ECS task defs). This is the
93
- * reliable trigger that makes the agent self-bootstrap — tool results
93
+ * reliable trigger that makes the agent self-bootstrap - tool results
94
94
  * always reach the model, which hook injection cannot guarantee on a
95
95
  * no-tool-call turn (e.g. Cursor "hello").
96
96
  *
97
- * v2.100 — Skill + IDE rule templates retired entirely. They were the
97
+ * v2.100 - Skill + IDE rule templates retired entirely. They were the
98
98
  * file-written home of the workflow; agents followed them unreliably and
99
99
  * forgot them over long sessions (the same fate these MCP instructions
100
100
  * suffer), so they are gone. `filesToWrite` now carries only
@@ -103,7 +103,7 @@
103
103
  * orchestration layer (host hooks + proxy); these instructions stay lean
104
104
  * and are the Config-A fallback only.
105
105
  *
106
- * v2.99 — Orchestration-aware framing. These static MCP instructions are
106
+ * v2.99 - Orchestration-aware framing. These static MCP instructions are
107
107
  * delivered at `initialize`, before the server knows the actor's tier
108
108
  * (no project/proxy/hooks state yet), so they cannot be served per-tier.
109
109
  * Instead the CONTENT is now tier-correct: the self-drive calls
@@ -114,29 +114,29 @@
114
114
  * The orchestration layer (proxy + host hooks) is authoritative for
115
115
  * B/C; this block is primarily Config-A's driver.
116
116
  *
117
- * v2.98 — Doc truth contracts are the normal drift surface. linkedDocs and
117
+ * v2.98 - Doc truth contracts are the normal drift surface. linkedDocs and
118
118
  * doc_code_couplings remain legacy diagnostics only; docs drift
119
119
  * defaults to truth findings and legacy:true is explicit debug.
120
- * v2.97 — Dropped legacy doc-coupling action guidance. Coupling rows are
120
+ * v2.97 - Dropped legacy doc-coupling action guidance. Coupling rows are
121
121
  * not truth findings (plan §1).
122
- * v2.96 — Replace conditional memory_discover guidance with structural fix:
122
+ * v2.96 - Replace conditional memory_discover guidance with structural fix:
123
123
  * bootstrap now includes _methodCatalog (discoverMemoryTools({})) so
124
124
  * agents have the memory_tools calling convention from turn one.
125
125
  * Instruction updated to reference _methodCatalog directly.
126
126
  * Superseded 2026-07-13: a real Config-B host persisted the expanded
127
127
  * 15.1KB hook output instead of injecting it. Bootstrap now carries a
128
128
  * compact discovery bridge; memory_discover({}) owns the full catalog.
129
- * v2.95 — Drop `augments` from JIT directive verdict list per plan §5 default
129
+ * v2.95 - Drop `augments` from JIT directive verdict list per plan §5 default
130
130
  * (augments downgrades to ambient via the parallel recall gate, not
131
131
  * the directive lane). See jit-knowledge-injector.md §5 decision note.
132
- * v2.94 — Add JIT directive teaching (docs/development/jit-knowledge-injector.md §2.3).
132
+ * v2.94 - Add JIT directive teaching (docs/development/jit-knowledge-injector.md §2.3).
133
133
  *
134
- * Code mode permanent — all memory operations via memory_tools sandbox.
134
+ * Code mode permanent - all memory operations via memory_tools sandbox.
135
135
  * memory_add, file_context etc. are now mnemonik.* methods, not standalone tools.
136
136
  *
137
137
  * Zero-cooperation rewrite. Context auto-loads if session_bootstrap is skipped.
138
138
  * Session summaries are auto-saved if agent doesn't call mnemonik.memory_add().
139
- * Instructions drastically simplified — the server handles the workflow now.
139
+ * Instructions drastically simplified - the server handles the workflow now.
140
140
  *
141
141
  * Token-optimised rewrite (superseded by later instruction rewrites).
142
142
  */
@@ -1,3 +1,4 @@
1
+ import { ensureWindowsPrivateDirectorySync } from './runtimeSigners.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import {
3
4
  chmodSync,
@@ -81,6 +82,7 @@ function ensurePrivateDirectory(path: string): boolean {
81
82
  if (!path) return false;
82
83
  try {
83
84
  if (existsSync(path) && lstatSync(path).isSymbolicLink()) return false;
85
+ ensureWindowsPrivateDirectorySync(path);
84
86
  mkdirSync(path, { recursive: true, mode: 0o700 });
85
87
  chmodSync(path, 0o700);
86
88
  return lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink();
@@ -0,0 +1,100 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { RepositoryFingerprint } from './repositoryFingerprint.js';
4
+
5
+ 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}$/;
6
+ export const isCanonicalUuid = (value: unknown): value is string =>
7
+ typeof value === 'string' && CANONICAL_UUID.test(value);
8
+ const IDENTITY_KEYS = new Set([
9
+ 'schemaVersion',
10
+ 'projectId',
11
+ 'projectName',
12
+ 'repositoryFingerprint',
13
+ ]);
14
+ const FINGERPRINT_KEYS = new Set(['algorithmVersion', 'hash']);
15
+
16
+ export interface ProjectIdentityFile {
17
+ schemaVersion: 1;
18
+ projectId: string;
19
+ projectName?: string;
20
+ repositoryFingerprint?: RepositoryFingerprint;
21
+ }
22
+
23
+ export type IdentityFileResult =
24
+ | { kind: 'ok'; identity: ProjectIdentityFile }
25
+ | { kind: 'unknown_version'; version: unknown }
26
+ | { kind: 'malformed'; detail: string }
27
+ | { kind: 'absent' };
28
+
29
+ export function parseIdentityFile(text: string): Exclude<IdentityFileResult, { kind: 'absent' }> {
30
+ let value: unknown;
31
+ try {
32
+ value = JSON.parse(text);
33
+ } catch (error) {
34
+ return { kind: 'malformed', detail: `unparseable JSON: ${(error as Error).message}` };
35
+ }
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
37
+ return { kind: 'malformed', detail: 'identity file must contain a JSON object' };
38
+ }
39
+ const record = value as Record<string, unknown>;
40
+ if (record.schemaVersion !== 1) {
41
+ return { kind: 'unknown_version', version: record.schemaVersion };
42
+ }
43
+ const unknownKey = Object.keys(record).find((key) => !IDENTITY_KEYS.has(key));
44
+ if (unknownKey) return { kind: 'malformed', detail: `unknown key "${unknownKey}"` };
45
+ if (!isCanonicalUuid(record.projectId)) {
46
+ return { kind: 'malformed', detail: '"projectId" must be a canonical RFC 4122 UUID string' };
47
+ }
48
+ if ('projectName' in record && typeof record.projectName !== 'string') {
49
+ return { kind: 'malformed', detail: '"projectName" must be a string when present' };
50
+ }
51
+ if (
52
+ 'repositoryFingerprint' in record &&
53
+ (!record.repositoryFingerprint ||
54
+ typeof record.repositoryFingerprint !== 'object' ||
55
+ Array.isArray(record.repositoryFingerprint))
56
+ ) {
57
+ return { kind: 'malformed', detail: '"repositoryFingerprint" must be an object when present' };
58
+ }
59
+ const fingerprint = record.repositoryFingerprint as Record<string, unknown> | undefined;
60
+ const unknownFingerprintKey = fingerprint
61
+ ? Object.keys(fingerprint).find((key) => !FINGERPRINT_KEYS.has(key))
62
+ : undefined;
63
+ if (unknownFingerprintKey) {
64
+ return {
65
+ kind: 'malformed',
66
+ detail: `unknown repositoryFingerprint key "${unknownFingerprintKey}"`,
67
+ };
68
+ }
69
+ if (fingerprint && (fingerprint.algorithmVersion !== 1 || typeof fingerprint.hash !== 'string')) {
70
+ return {
71
+ kind: 'malformed',
72
+ detail: '"repositoryFingerprint" must contain algorithmVersion 1 and a string hash',
73
+ };
74
+ }
75
+ const repositoryFingerprint: RepositoryFingerprint | undefined = fingerprint
76
+ ? { algorithmVersion: 1, hash: fingerprint.hash as string }
77
+ : undefined;
78
+ return {
79
+ kind: 'ok',
80
+ identity: {
81
+ schemaVersion: 1,
82
+ projectId: record.projectId,
83
+ ...(record.projectName === undefined ? {} : { projectName: record.projectName as string }),
84
+ ...(repositoryFingerprint ? { repositoryFingerprint } : {}),
85
+ },
86
+ };
87
+ }
88
+
89
+ export async function readIdentityFile(dir: string): Promise<IdentityFileResult> {
90
+ const path = join(dir, '.mnemonik.json');
91
+ try {
92
+ const stat = await lstat(path);
93
+ if (stat.isSymbolicLink()) return { kind: 'malformed', detail: 'symlink' };
94
+ if (!stat.isFile()) return { kind: 'malformed', detail: 'identity path is not a regular file' };
95
+ return parseIdentityFile(await readFile(path, 'utf8'));
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' };
98
+ return { kind: 'malformed', detail: `cannot read identity file: ${(error as Error).message}` };
99
+ }
100
+ }
@@ -0,0 +1,275 @@
1
+ import { protectWindowsDirectory } from './runtimeSigners.js';
2
+ import { execFile } from 'node:child_process';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, relative } from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { RuntimeReader, hash } from './runtimeReader.js';
9
+
10
+ export const PROJECT_SETUP_MANUAL =
11
+ 'Mnemonik project setup needs attention; ask the person to run `mnemonik project init` in this folder.';
12
+ const words = (value: unknown): string =>
13
+ typeof value === 'string' && /^[a-z][a-z0-9_]{0,79}$/.test(value)
14
+ ? value.replaceAll('_', ' ')
15
+ : 'needs attention';
16
+ export function projectSetupActions(value: unknown): string {
17
+ return Array.isArray(value) ? value.slice(0, 20).map(words).join(', ') : 'retry, cancel';
18
+ }
19
+ export interface ProjectSetupDiagnostic {
20
+ requestId?: string;
21
+ outcome: 'done' | 'ACTION_REQUIRED' | 'failed';
22
+ time: string;
23
+ rootHash: string | null;
24
+ action: string;
25
+ reason?: string;
26
+ }
27
+ const record = (value: unknown): value is Record<string, unknown> =>
28
+ !!value && typeof value === 'object' && !Array.isArray(value);
29
+ /** Parse the native tool response, never tool arguments or agent-authored command fields. */
30
+ export function setupResponse(value: unknown): Record<string, unknown> | undefined {
31
+ if (typeof value === 'string') {
32
+ if (value.length > 64 * 1024) return;
33
+ try {
34
+ value = JSON.parse(value);
35
+ } catch {
36
+ return;
37
+ }
38
+ }
39
+ if (!record(value)) return;
40
+ if (record(value.structuredContent)) value = value.structuredContent;
41
+ else {
42
+ // Claude supplies the server's tool_response directly, not model-authored text.
43
+ // Accept only the server envelope; no command/path extensions cross this boundary.
44
+ const keys = ['status', 'state', 'allowedActions', 'requestId', 'expiresAt'];
45
+ const envelope = value;
46
+ if (
47
+ !keys.every((key) => Object.hasOwn(envelope, key)) ||
48
+ Object.keys(value).some((key) => ![...keys, 'manualAction'].includes(key)) ||
49
+ typeof value.state !== 'string' ||
50
+ !Array.isArray(value.allowedActions) ||
51
+ !value.allowedActions.every((action) => typeof action === 'string') ||
52
+ typeof value.requestId !== 'string' ||
53
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
54
+ value.requestId
55
+ ) ||
56
+ typeof value.expiresAt !== 'string' ||
57
+ !Number.isFinite(Date.parse(value.expiresAt)) ||
58
+ (Object.hasOwn(value, 'manualAction') && typeof value.manualAction !== 'string')
59
+ )
60
+ return;
61
+ }
62
+ if (record(value) && value.status === 'project_setup_required') return value;
63
+ return;
64
+ }
65
+ async function privateDirectory(path: string, session: string): Promise<void> {
66
+ const created = await mkdir(path, { recursive: true, mode: 0o700 });
67
+ const st = await lstat(path);
68
+ if (
69
+ !st.isDirectory() ||
70
+ st.isSymbolicLink() ||
71
+ (process.getuid && (st.uid !== process.getuid() || st.mode & 0o077))
72
+ )
73
+ throw new Error('diagnostic_unavailable');
74
+ if (process.platform === 'win32') {
75
+ await protectWindowsDirectory(path, created !== undefined, undefined, session);
76
+ }
77
+ }
78
+ async function readPrivate(path: string): Promise<string> {
79
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
80
+ try {
81
+ const st = await file.stat();
82
+ if (
83
+ !st.isFile() ||
84
+ st.size > 64 * 1024 ||
85
+ (process.getuid && (st.uid !== process.getuid() || st.mode & 0o077))
86
+ )
87
+ throw new Error('diagnostic_unavailable');
88
+ return await file.readFile('utf8');
89
+ } finally {
90
+ await file.close();
91
+ }
92
+ }
93
+ async function writeDiagnostic(path: string, value: ProjectSetupDiagnostic): Promise<void> {
94
+ await privateDirectory(dirname(dirname(path)), dirname(path));
95
+ await privateDirectory(dirname(path), dirname(path));
96
+ const temp = `${path}.${randomUUID()}`;
97
+ try {
98
+ const file = await open(temp, 'wx', 0o600);
99
+ try {
100
+ await file.writeFile(JSON.stringify(value) + '\n');
101
+ } finally {
102
+ await file.close();
103
+ }
104
+ await rename(temp, path);
105
+ } finally {
106
+ await rm(temp, { force: true });
107
+ }
108
+ }
109
+
110
+ export async function handoffProjectSetup(input: {
111
+ response: unknown;
112
+ cwd: string | undefined;
113
+ host: 'claude_code' | 'cursor' | 'grok';
114
+ hostSessionId: string;
115
+ stateFile: string;
116
+ stateDir: string;
117
+ familyId: string | undefined;
118
+ }): Promise<string | undefined> {
119
+ const response = setupResponse(input.response);
120
+ if (!response) return;
121
+ let root: string | undefined;
122
+ let action = PROJECT_SETUP_MANUAL;
123
+ let outcome: ProjectSetupDiagnostic['outcome'] = 'failed';
124
+ let reason = 'manual_setup_required';
125
+ const requestId =
126
+ typeof response.requestId === 'string' && response.requestId.length <= 4096
127
+ ? response.requestId
128
+ : undefined;
129
+ try {
130
+ if (!input.cwd) throw new Error('safe_cwd_unavailable');
131
+ // The binding receipt is private local state. No path from the response is read.
132
+ const cache = JSON.parse(
133
+ await readPrivate(join(dirname(input.stateFile), 'bound-context.json'))
134
+ );
135
+ const [, family, body] = JSON.parse(cache.key);
136
+ if (
137
+ !input.familyId ||
138
+ family !== input.familyId ||
139
+ body.host !== input.host ||
140
+ body.hostSessionId !== input.hostSessionId ||
141
+ cache.expiresAt <= Date.now() ||
142
+ !cache.canonicalRoot
143
+ )
144
+ throw new Error('bound_context_unavailable');
145
+ root = await realpath(cache.canonicalRoot);
146
+ const { findProjectIdentityDetailed } = await import('./hookRuntime.js');
147
+ const identity = await findProjectIdentityDetailed(await realpath(input.cwd));
148
+ if (identity.kind === 'git_unavailable' || root !== (await realpath(identity.root)))
149
+ throw new Error('bound_root_changed');
150
+ if (!requestId) throw new Error('manual_setup_required');
151
+ const runtime = await new RuntimeReader(input.stateDir).verifyRuntime('cli').catch(() => {
152
+ throw new Error('verified_cli_runtime_unavailable');
153
+ });
154
+ const bin = join(dirname(runtime.entry), 'bin.js');
155
+ if (!runtime.manifest.files[relative(runtime.directory, bin).replaceAll('\\', '/')])
156
+ throw new Error('verified_cli_bin_missing');
157
+ const result = await new Promise<unknown>((resolve, reject) => {
158
+ const child = execFile(
159
+ process.execPath,
160
+ [bin, 'project', 'ensure', '--agent', '--json'],
161
+ {
162
+ cwd: root,
163
+ shell: false,
164
+ timeout: 30_000,
165
+ killSignal: 'SIGKILL',
166
+ maxBuffer: 64 * 1024,
167
+ // Runtime injection variables and repository/PATH executables are not inherited.
168
+ env: Object.fromEntries(
169
+ [
170
+ 'HOME',
171
+ 'USERPROFILE',
172
+ 'LOCALAPPDATA',
173
+ 'XDG_STATE_HOME',
174
+ 'MNEMONIK_STATE_DIR',
175
+ 'MNEMONIK_SERVER',
176
+ 'MNEMONIK_API_RESOURCE',
177
+ 'MNEMONIK_OAUTH_ISSUER',
178
+ 'SYSTEMROOT',
179
+ 'TEMP',
180
+ 'TMP',
181
+ 'TMPDIR',
182
+ ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]]))
183
+ ),
184
+ },
185
+ (error, stdout) => {
186
+ if (error && (error.killed || error.code !== 3))
187
+ return reject(new Error('helper_failed'));
188
+ try {
189
+ resolve(JSON.parse(stdout));
190
+ } catch {
191
+ reject(new Error('helper_invalid_json'));
192
+ }
193
+ }
194
+ );
195
+ child.stdin?.on('error', () => {});
196
+ child.stdin?.end(JSON.stringify({ requestId }));
197
+ });
198
+ if (!record(result)) throw new Error('helper_invalid_json');
199
+ if (result.status === 'done') {
200
+ outcome = 'done';
201
+ reason = 'complete';
202
+ action = 'Mnemonik project setup is complete; call session_bootstrap again.';
203
+ } else if (
204
+ ['ACTION_REQUIRED', 'action_required', 'project_setup_required'].includes(
205
+ String(result.status)
206
+ )
207
+ ) {
208
+ outcome = 'ACTION_REQUIRED';
209
+ reason = 'choice_required';
210
+ action =
211
+ result.action === 'mnemonik auth renew' ||
212
+ (Array.isArray(result.allowedActions) &&
213
+ result.allowedActions.includes('mnemonik auth renew'))
214
+ ? 'mnemonik auth renew'
215
+ : result.allowedActions
216
+ ? `Mnemonik project setup needs attention; allowed actions: ${projectSetupActions(result.allowedActions)}.`
217
+ : PROJECT_SETUP_MANUAL;
218
+ } else throw new Error('helper_invalid_json');
219
+ } catch (error) {
220
+ const code = error instanceof Error ? error.message : '';
221
+ reason = /^[a-z_]+$/.test(code) ? code : 'helper_unavailable';
222
+ process.stderr.write(`mnemonik-hook: project setup ${reason}; manual action required\n`);
223
+ }
224
+ try {
225
+ await writeDiagnostic(input.stateFile, {
226
+ requestId,
227
+ outcome,
228
+ time: new Date().toISOString(),
229
+ rootHash: root ? hash(root) : input.cwd ? hash(await realpath(input.cwd)) : null,
230
+ action,
231
+ reason,
232
+ });
233
+ } catch {
234
+ process.stderr.write('mnemonik-hook: project setup diagnostic unavailable\n');
235
+ }
236
+ return action;
237
+ }
238
+
239
+ /** Both doctor and status consume private per-session outcomes, scoped by root hash. */
240
+ export async function pendingProjectSetup(root: string): Promise<ProjectSetupDiagnostic[]> {
241
+ const rootHash = hash(await realpath(root));
242
+ const results: ProjectSetupDiagnostic[] = [];
243
+ const uid = process.getuid?.() ?? 'user';
244
+ for (const host of ['credit', 'cursor', 'grok']) {
245
+ const directory = join(tmpdir(), `mnemonik-${host}-${uid}`);
246
+ try {
247
+ const stat = await lstat(directory);
248
+ if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
249
+ for (const name of await readdir(directory)) {
250
+ if (!/^[a-f0-9]{64}$/.test(name)) continue;
251
+ try {
252
+ if ((await lstat(join(directory, name))).isSymbolicLink()) continue;
253
+ const diagnostic = JSON.parse(
254
+ await readPrivate(join(directory, name, 'project-setup.json'))
255
+ );
256
+ if (
257
+ (diagnostic.rootHash === rootHash || diagnostic.rootHash === null) &&
258
+ ['done', 'ACTION_REQUIRED', 'failed'].includes(diagnostic.outcome) &&
259
+ typeof diagnostic.action === 'string' &&
260
+ diagnostic.action.length < 2000 &&
261
+ Date.now() - Date.parse(diagnostic.time) < 24 * 60 * 60 * 1000
262
+ )
263
+ results.push(diagnostic);
264
+ } catch {
265
+ /* An incomplete or removed session is not a pending action. */
266
+ }
267
+ }
268
+ } catch {
269
+ /* Host has no local sessions. */
270
+ }
271
+ }
272
+ // A later completion supersedes stale actions from another session in this root.
273
+ results.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
274
+ return results[0]?.outcome === 'done' ? [] : results.slice(0, 1);
275
+ }
@@ -0,0 +1,88 @@
1
+ import { homedir } from 'node:os';
2
+ import { posix, win32, type PlatformPath } from 'node:path';
3
+
4
+ type Platform = NodeJS.Platform;
5
+
6
+ function pathsFor(platform: Platform): PlatformPath {
7
+ return platform === 'win32' ? win32 : posix;
8
+ }
9
+
10
+ /** Local state and credential locations that Mnemonik must never scan. */
11
+ export function protectedLocalPaths(
12
+ platform: Platform = process.platform,
13
+ env: NodeJS.ProcessEnv = process.env,
14
+ home: string = homedir()
15
+ ): string[] {
16
+ const path = pathsFor(platform);
17
+ const localAppData = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local');
18
+ 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'));
26
+ const common = [
27
+ state,
28
+ path.join(home, '.mnemonik'),
29
+ path.join(home, '.claude', '.credentials.json'),
30
+ path.join(home, '.codex', 'auth.json'),
31
+ path.join(home, '.cursor', 'mcp.json'),
32
+ path.join(home, '.grok', 'mcp_credentials.json'),
33
+ ];
34
+ const osPaths =
35
+ platform === 'darwin'
36
+ ? [path.join(home, 'Library', 'Keychains')]
37
+ : platform === 'win32'
38
+ ? [
39
+ win32.join(appData, 'Microsoft', 'Credentials'),
40
+ win32.join(localAppData, 'Microsoft', 'Credentials'),
41
+ win32.join(appData, 'Microsoft', 'Protect'),
42
+ ]
43
+ : [
44
+ path.join(home, '.local', 'share', 'keyrings'),
45
+ path.join(home, '.gnupg'),
46
+ path.join(home, '.ssh'),
47
+ path.join(home, '.aws'),
48
+ path.join(home, '.config', 'gcloud'),
49
+ path.join(home, '.docker', 'config.json'),
50
+ path.join(home, '.npmrc'),
51
+ path.join(home, '.netrc'),
52
+ path.join(home, '.pypirc'),
53
+ ];
54
+ return [...new Set([...common, ...osPaths])];
55
+ }
56
+
57
+ function comparable(value: string, platform: Platform): string {
58
+ const path = pathsFor(platform);
59
+ const normalized = path.resolve(value);
60
+ return platform === 'win32' || platform === 'darwin' ? normalized.toLowerCase() : normalized;
61
+ }
62
+
63
+ /** True when candidate is the protected path itself or lies below it. */
64
+ export function isProtectedLocalPath(
65
+ candidate: string,
66
+ protectedPaths: readonly string[] = protectedLocalPaths(),
67
+ platform: Platform = process.platform
68
+ ): boolean {
69
+ const path = pathsFor(platform);
70
+ const target = comparable(candidate, platform);
71
+ return protectedPaths.some((protectedPath) => {
72
+ const parent = comparable(protectedPath, platform);
73
+ const relative = path.relative(parent, target);
74
+ return (
75
+ relative === '' ||
76
+ (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
77
+ );
78
+ });
79
+ }
80
+
81
+ /** Protected paths equal to or below root, for consent auto-exclusions. */
82
+ export function protectedPathsWithinRoot(
83
+ root: string,
84
+ protectedPaths: readonly string[] = protectedLocalPaths(),
85
+ platform: Platform = process.platform
86
+ ): string[] {
87
+ return protectedPaths.filter((candidate) => isProtectedLocalPath(candidate, [root], platform));
88
+ }