memorix 1.4.6 → 1.5.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 (119) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +225 -70
  4. package/dist/cli/memcode.js +0 -0
  5. package/dist/index.js +225 -70
  6. package/dist/maintenance-runner.js +183 -38
  7. package/dist/media-audio-runner.js +183 -38
  8. package/dist/media-video-runner.js +183 -38
  9. package/dist/memcode-runtime/CHANGELOG.md +38 -0
  10. package/dist/sdk.js +224 -69
  11. package/dist/types.d.ts +2 -0
  12. package/dist/vector-backfill-runner.js +183 -38
  13. package/docs/CONFIGURATION.md +12 -0
  14. package/docs/DESIGN_DECISIONS.md +357 -357
  15. package/package.json +1 -1
  16. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  17. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  18. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  19. package/plugins/copilot/memorix/plugin.json +1 -1
  20. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  21. package/plugins/omp/memorix/package.json +1 -1
  22. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/pi/memorix/package.json +1 -1
  24. package/server.json +2 -2
  25. package/src/audit/index.ts +156 -156
  26. package/src/cli/commands/formation.ts +48 -48
  27. package/src/cli/commands/git-hook-install.ts +111 -111
  28. package/src/cli/commands/handoff.ts +54 -54
  29. package/src/cli/commands/hooks-status.ts +63 -63
  30. package/src/cli/commands/ingest.ts +44 -44
  31. package/src/cli/commands/integrate-shared.ts +15 -15
  32. package/src/cli/commands/lock.ts +82 -82
  33. package/src/cli/commands/message.ts +104 -104
  34. package/src/cli/commands/poll.ts +58 -58
  35. package/src/cli/commands/reasoning.ts +118 -118
  36. package/src/cli/commands/skills.ts +114 -114
  37. package/src/cli/commands/task.ts +167 -167
  38. package/src/cli/commands/transfer.ts +47 -47
  39. package/src/cli/index.ts +406 -406
  40. package/src/cli/tui/ChatView.tsx +234 -234
  41. package/src/cli/tui/CommandBar.tsx +312 -312
  42. package/src/cli/tui/ContextRail.tsx +118 -118
  43. package/src/cli/tui/HeaderBar.tsx +72 -72
  44. package/src/cli/tui/LogoBanner.tsx +51 -51
  45. package/src/cli/tui/index.ts +41 -41
  46. package/src/cli/version.ts +7 -7
  47. package/src/cli/workbench.ts +1 -1
  48. package/src/config.ts +23 -2
  49. package/src/dashboard/project-classification.ts +64 -64
  50. package/src/embedding/api-provider.ts +105 -22
  51. package/src/embedding/provider.ts +2 -0
  52. package/src/git/hooks-path.ts +85 -85
  53. package/src/hooks/pattern-detector.ts +173 -173
  54. package/src/hooks/significance-filter.ts +250 -250
  55. package/src/llm/memory-manager.ts +328 -328
  56. package/src/llm/provider.ts +17 -8
  57. package/src/llm/quality.ts +248 -248
  58. package/src/memory/attribution-guard.ts +249 -249
  59. package/src/memory/disclosure-policy.ts +135 -135
  60. package/src/memory/entity-extractor.ts +197 -197
  61. package/src/memory/formation/evaluate.ts +217 -217
  62. package/src/memory/formation/extract.ts +6 -3
  63. package/src/memory/formation/index.ts +2 -1
  64. package/src/memory/formation/resolve.ts +10 -6
  65. package/src/memory/formation/types.ts +3 -1
  66. package/src/memory/freshness.ts +122 -122
  67. package/src/memory/graph.ts +197 -197
  68. package/src/memory/long-term.ts +7 -1
  69. package/src/memory/refs.ts +94 -94
  70. package/src/memory/secret-filter.ts +79 -79
  71. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  72. package/src/orchestrate/adapters/claude.ts +111 -111
  73. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  74. package/src/orchestrate/adapters/codex.ts +41 -41
  75. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  76. package/src/orchestrate/adapters/gemini.ts +42 -42
  77. package/src/orchestrate/adapters/index.ts +73 -73
  78. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  79. package/src/orchestrate/adapters/opencode.ts +47 -47
  80. package/src/orchestrate/adapters/types.ts +77 -77
  81. package/src/orchestrate/capability-router.ts +284 -284
  82. package/src/orchestrate/cost-tracker.ts +219 -219
  83. package/src/orchestrate/error-recovery.ts +191 -191
  84. package/src/orchestrate/evidence.ts +140 -140
  85. package/src/orchestrate/ledger.ts +110 -110
  86. package/src/orchestrate/memorix-bridge.ts +343 -343
  87. package/src/orchestrate/output-budget.ts +80 -80
  88. package/src/orchestrate/permission.ts +152 -152
  89. package/src/orchestrate/pipeline-trace.ts +131 -131
  90. package/src/orchestrate/prompt-builder.ts +155 -155
  91. package/src/orchestrate/ring-buffer.ts +37 -37
  92. package/src/orchestrate/task-graph.ts +389 -389
  93. package/src/project/aliases.ts +374 -374
  94. package/src/rules/adapters/trae.ts +56 -56
  95. package/src/rules/syncer.ts +235 -235
  96. package/src/search/query-expansion.ts +52 -52
  97. package/src/server/formation-timeout.ts +27 -27
  98. package/src/server.ts +14 -10
  99. package/src/skills/mini-skills.ts +386 -386
  100. package/src/store/chat-store.ts +119 -119
  101. package/src/store/graph-store.ts +249 -249
  102. package/src/store/mini-skill-store.ts +349 -349
  103. package/src/store/orama-store.ts +6 -4
  104. package/src/store/persistence-json.ts +212 -212
  105. package/src/store/persistence.ts +291 -291
  106. package/src/store/project-affinity.ts +195 -195
  107. package/src/team/event-bus.ts +76 -76
  108. package/src/team/handoff.ts +161 -161
  109. package/src/team/poll.ts +132 -132
  110. package/src/timeout.ts +67 -0
  111. package/src/types.ts +2 -0
  112. package/src/workspace/mcp-adapters/codex.ts +191 -191
  113. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  114. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  115. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  116. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  117. package/src/workspace/mcp-adapters/trae.ts +134 -134
  118. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  119. package/src/workspace/sanitizer.ts +60 -60
@@ -1,64 +1,64 @@
1
- /**
2
- * Project classification helpers — shared by /api/projects and /api/identity.
3
- *
4
- * Three kinds:
5
- * - 'real': genuine user projects (e.g. AVIDS2/memorix, github.com/org/repo)
6
- * - 'temporary': test/demo/smoke/e2e scratch projects (local/task-*, local/smoke-*, etc.)
7
- * - 'placeholder': unresolved / obviously broken IDs (placeholder/*, __unresolved__, System32)
8
- *
9
- * A 'dirty' project is one with a clearly broken canonical ID (System32 etc.).
10
- * 'dirty' and 'temporary' are orthogonal axes:
11
- * - local/task-abc → temporary, NOT dirty
12
- * - placeholder/xxx → placeholder, dirty
13
- * - System32\something → real-looking location but dirty (broken ID)
14
- */
15
-
16
- export type ProjectKind = 'real' | 'temporary' | 'placeholder';
17
-
18
- /** Regex list — anything matching is temporary (scratch projects) */
19
- const TEMPORARY_PATTERNS: RegExp[] = [
20
- /^local\/task-/i,
21
- /^local\/smoke-/i,
22
- /^local\/release-smoke-/i,
23
- /^local\/memorix-e2e-/i,
24
- /^local\/orchestrate-/i,
25
- /^local\/scratch-/i,
26
- /^local\/tmp-/i,
27
- ];
28
-
29
- /** Regex list — anything matching is placeholder/unresolved */
30
- const PLACEHOLDER_PATTERNS: RegExp[] = [
31
- /^__unresolved__$/,
32
- /^placeholder\//i,
33
- ];
34
-
35
- /** Regex list — IDs that indicate a broken canonical ID (dirty). */
36
- const DIRTY_PATTERNS: RegExp[] = [
37
- /^placeholder\//i,
38
- /System32/i,
39
- /Microsoft VS Code/i,
40
- /node_modules/i,
41
- /\.vscode/i,
42
- /^local\/[A-Z]:\\/i,
43
- ];
44
-
45
- export function classifyProjectId(id: string): ProjectKind {
46
- if (!id) return 'placeholder';
47
- if (PLACEHOLDER_PATTERNS.some(p => p.test(id))) return 'placeholder';
48
- if (TEMPORARY_PATTERNS.some(p => p.test(id))) return 'temporary';
49
- return 'real';
50
- }
51
-
52
- export function isDirtyProjectId(id: string): boolean {
53
- if (!id) return false;
54
- return DIRTY_PATTERNS.some(p => p.test(id));
55
- }
56
-
57
- /** Friendly label for UI badges */
58
- export function projectKindLabel(kind: ProjectKind): string {
59
- switch (kind) {
60
- case 'real': return 'real';
61
- case 'temporary': return 'temporary';
62
- case 'placeholder': return 'placeholder';
63
- }
64
- }
1
+ /**
2
+ * Project classification helpers — shared by /api/projects and /api/identity.
3
+ *
4
+ * Three kinds:
5
+ * - 'real': genuine user projects (e.g. AVIDS2/memorix, github.com/org/repo)
6
+ * - 'temporary': test/demo/smoke/e2e scratch projects (local/task-*, local/smoke-*, etc.)
7
+ * - 'placeholder': unresolved / obviously broken IDs (placeholder/*, __unresolved__, System32)
8
+ *
9
+ * A 'dirty' project is one with a clearly broken canonical ID (System32 etc.).
10
+ * 'dirty' and 'temporary' are orthogonal axes:
11
+ * - local/task-abc → temporary, NOT dirty
12
+ * - placeholder/xxx → placeholder, dirty
13
+ * - System32\something → real-looking location but dirty (broken ID)
14
+ */
15
+
16
+ export type ProjectKind = 'real' | 'temporary' | 'placeholder';
17
+
18
+ /** Regex list — anything matching is temporary (scratch projects) */
19
+ const TEMPORARY_PATTERNS: RegExp[] = [
20
+ /^local\/task-/i,
21
+ /^local\/smoke-/i,
22
+ /^local\/release-smoke-/i,
23
+ /^local\/memorix-e2e-/i,
24
+ /^local\/orchestrate-/i,
25
+ /^local\/scratch-/i,
26
+ /^local\/tmp-/i,
27
+ ];
28
+
29
+ /** Regex list — anything matching is placeholder/unresolved */
30
+ const PLACEHOLDER_PATTERNS: RegExp[] = [
31
+ /^__unresolved__$/,
32
+ /^placeholder\//i,
33
+ ];
34
+
35
+ /** Regex list — IDs that indicate a broken canonical ID (dirty). */
36
+ const DIRTY_PATTERNS: RegExp[] = [
37
+ /^placeholder\//i,
38
+ /System32/i,
39
+ /Microsoft VS Code/i,
40
+ /node_modules/i,
41
+ /\.vscode/i,
42
+ /^local\/[A-Z]:\\/i,
43
+ ];
44
+
45
+ export function classifyProjectId(id: string): ProjectKind {
46
+ if (!id) return 'placeholder';
47
+ if (PLACEHOLDER_PATTERNS.some(p => p.test(id))) return 'placeholder';
48
+ if (TEMPORARY_PATTERNS.some(p => p.test(id))) return 'temporary';
49
+ return 'real';
50
+ }
51
+
52
+ export function isDirtyProjectId(id: string): boolean {
53
+ if (!id) return false;
54
+ return DIRTY_PATTERNS.some(p => p.test(id));
55
+ }
56
+
57
+ /** Friendly label for UI badges */
58
+ export function projectKindLabel(kind: ProjectKind): string {
59
+ switch (kind) {
60
+ case 'real': return 'real';
61
+ case 'temporary': return 'temporary';
62
+ case 'placeholder': return 'placeholder';
63
+ }
64
+ }
@@ -406,6 +406,16 @@ interface APIEmbeddingConfig {
406
406
  requestedDimensions: number | null;
407
407
  }
408
408
 
409
+ class EmbeddingAPIError extends Error {
410
+ readonly status: number;
411
+
412
+ constructor(status: number, detail: string) {
413
+ super(`Embedding API error (${status}): ${detail}`);
414
+ this.name = 'EmbeddingAPIError';
415
+ this.status = status;
416
+ }
417
+ }
418
+
409
419
  export interface APIEmbeddingProviderCreateOptions {
410
420
  /**
411
421
  * When false, only previously persisted dimension metadata may initialize
@@ -432,6 +442,14 @@ function getPreferredBatchSize(config: APIEmbeddingConfig): number {
432
442
  function parseBatchLimit(error: unknown): number | null {
433
443
  if (!(error instanceof Error)) return null;
434
444
 
445
+ // Only a provider-side request-shape error may trigger recursive batch
446
+ // splitting. Billing, authentication, quota, and upstream errors must fail
447
+ // once; retrying them multiplies latency without changing the request.
448
+ const status = error instanceof EmbeddingAPIError
449
+ ? error.status
450
+ : Number(error.message.match(/Embedding API error \((\d{3})\)/i)?.[1] ?? 0);
451
+ if (status > 0 && status !== 400) return null;
452
+
435
453
  const explicit = error.message.match(/should not be larger than\s+(\d+)/i);
436
454
  if (explicit) return parseInt(explicit[1], 10);
437
455
 
@@ -712,16 +730,14 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
712
730
  }
713
731
  } catch (error) {
714
732
  const providerLimit = parseBatchLimit(error);
715
- const fallbackSize = providerLimit ?? Math.ceil(chunkTexts.length / 2);
716
-
717
- if (chunkTexts.length > 1 && fallbackSize < chunkTexts.length) {
733
+ if (providerLimit !== null && chunkTexts.length > 1 && providerLimit < chunkTexts.length) {
718
734
  console.error(
719
- `[memorix] Embedding batch too large for provider, retrying in chunks of ${fallbackSize}`,
735
+ `[memorix] Embedding batch too large for provider, retrying in chunks of ${providerLimit}`,
720
736
  );
721
- for (let start = 0; start < chunkTexts.length; start += fallbackSize) {
737
+ for (let start = 0; start < chunkTexts.length; start += providerLimit) {
722
738
  await processChunk(
723
- chunkTexts.slice(start, start + fallbackSize),
724
- chunkIndices.slice(start, start + fallbackSize),
739
+ chunkTexts.slice(start, start + providerLimit),
740
+ chunkIndices.slice(start, start + providerLimit),
725
741
  );
726
742
  }
727
743
  return;
@@ -842,6 +858,9 @@ async function fetchWithRetry(
842
858
  ? Math.max(100, Math.min(Math.floor(options.timeoutMs), 60_000))
843
859
  : 10_000;
844
860
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
861
+ const signal = options.signal
862
+ ? AbortSignal.any([options.signal, controller.signal])
863
+ : controller.signal;
845
864
  let response: Response;
846
865
  try {
847
866
  response = await fetch(url, {
@@ -850,30 +869,94 @@ async function fetchWithRetry(
850
869
  ? { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }
851
870
  : { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
852
871
  body: JSON.stringify(body),
853
- signal: controller.signal,
872
+ signal,
854
873
  });
855
874
  } catch (err: unknown) {
856
875
  clearTimeout(timeout);
857
- if (err instanceof Error && err.name === 'AbortError') {
876
+ if (err instanceof Error && (err.name === 'AbortError' || controller.signal.aborted || options.signal?.aborted)) {
858
877
  throw new Error(`Embedding API timeout after ${timeoutMs}ms: ${url}`);
859
878
  }
860
879
  throw err;
861
880
  }
862
- clearTimeout(timeout);
863
881
 
864
- if (response.ok) {
865
- return response.json() as Promise<EmbeddingAPIResponse>;
866
- }
882
+ try {
883
+ if (response.ok) {
884
+ return await raceWithAbort(
885
+ response.json() as Promise<EmbeddingAPIResponse>,
886
+ signal,
887
+ );
888
+ }
889
+
890
+ if (options.retry !== false && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
891
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt);
892
+ const retryAfter = response.headers.get('retry-after');
893
+ const parsedRetryAfter = retryAfter ? parseInt(retryAfter, 10) * 1000 : NaN;
894
+ const waitMs = Number.isFinite(parsedRetryAfter) ? parsedRetryAfter : delay;
895
+ console.error(`[memorix] Embedding API ${response.status}, retry ${attempt + 1}/${MAX_RETRIES} in ${waitMs}ms`);
896
+ await waitForRetry(waitMs, options.signal);
897
+ return fetchWithRetry(url, apiKey, body, options, attempt + 1, nativeGemini);
898
+ }
867
899
 
868
- if (options.retry !== false && (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
869
- const delay = BASE_DELAY_MS * Math.pow(2, attempt);
870
- const retryAfter = response.headers.get('retry-after');
871
- const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;
872
- console.error(`[memorix] Embedding API ${response.status}, retry ${attempt + 1}/${MAX_RETRIES} in ${waitMs}ms`);
873
- await new Promise(resolve => setTimeout(resolve, waitMs));
874
- return fetchWithRetry(url, apiKey, body, options, attempt + 1, nativeGemini);
900
+ const errorText = await raceWithAbort(
901
+ response.text().catch(() => 'unknown error'),
902
+ signal,
903
+ );
904
+ throw new EmbeddingAPIError(response.status, errorText);
905
+ } catch (err: unknown) {
906
+ if (err instanceof Error && (err.name === 'AbortError' || controller.signal.aborted || options.signal?.aborted)) {
907
+ throw new Error(`Embedding API timeout after ${timeoutMs}ms: ${url}`);
908
+ }
909
+ throw err;
910
+ } finally {
911
+ clearTimeout(timeout);
875
912
  }
913
+ }
876
914
 
877
- const errorText = await response.text().catch(() => 'unknown error');
878
- throw new Error(`Embedding API error (${response.status}): ${errorText}`);
915
+ function abortError(): Error {
916
+ const error = new Error('Embedding API request aborted');
917
+ error.name = 'AbortError';
918
+ return error;
919
+ }
920
+
921
+ function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
922
+ if (signal.aborted) return Promise.reject(abortError());
923
+ return new Promise<T>((resolve, reject) => {
924
+ const onAbort = () => {
925
+ signal.removeEventListener('abort', onAbort);
926
+ reject(abortError());
927
+ };
928
+ signal.addEventListener('abort', onAbort, { once: true });
929
+ promise.then(
930
+ (value) => {
931
+ signal.removeEventListener('abort', onAbort);
932
+ resolve(value);
933
+ },
934
+ (error) => {
935
+ signal.removeEventListener('abort', onAbort);
936
+ reject(error);
937
+ },
938
+ );
939
+ });
940
+ }
941
+
942
+ async function waitForRetry(waitMs: number, signal?: AbortSignal): Promise<void> {
943
+ if (!signal) {
944
+ await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
945
+ return;
946
+ }
947
+ const abortSignal = signal;
948
+ if (abortSignal.aborted) throw new Error('Embedding API retry aborted');
949
+ await new Promise<void>((resolve, reject) => {
950
+ const timer = setTimeout(done, waitMs);
951
+ const onAbort = () => {
952
+ clearTimeout(timer);
953
+ abortSignal.removeEventListener('abort', onAbort);
954
+ reject(new Error('Embedding API retry aborted'));
955
+ };
956
+ function done() {
957
+ abortSignal.removeEventListener('abort', onAbort);
958
+ resolve();
959
+ }
960
+ abortSignal.addEventListener('abort', onAbort, { once: true });
961
+ });
879
962
  }
@@ -34,6 +34,8 @@ export interface EmbeddingRequestOptions {
34
34
  timeoutMs?: number;
35
35
  /** Disable provider retries for latency-sensitive best-effort work. */
36
36
  retry?: boolean;
37
+ /** Abort the request and any retry wait when the caller's budget expires. */
38
+ signal?: AbortSignal;
37
39
  }
38
40
 
39
41
  export type EmbeddingModality = 'text' | 'image' | 'audio' | 'video' | 'document';
@@ -1,85 +1,85 @@
1
- /**
2
- * Git hooks path resolver — worktree-safe.
3
- *
4
- * In a normal repo: .git is a directory → hooks at .git/hooks/
5
- * In a git worktree: .git is a FILE containing "gitdir: /path/to/actual/git/dir"
6
- * → hooks at that resolved path + /hooks/
7
- *
8
- * This utility ensures all hook install/uninstall/check operations
9
- * work correctly in both scenarios.
10
- */
11
-
12
- import { existsSync, readFileSync, statSync, mkdirSync } from 'node:fs';
13
- import path from 'node:path';
14
-
15
- /**
16
- * Resolve the actual .git directory path, following worktree indirection.
17
- *
18
- * @param projectRoot - The root of the working tree (where .git lives)
19
- * @returns Absolute path to the real git dir, or null if no .git found
20
- */
21
- export function resolveGitDir(projectRoot: string): string | null {
22
- const dotGit = path.join(projectRoot, '.git');
23
-
24
- if (!existsSync(dotGit)) return null;
25
-
26
- const stat = statSync(dotGit);
27
-
28
- if (stat.isDirectory()) {
29
- // Normal repo — .git is a directory
30
- return dotGit;
31
- }
32
-
33
- if (stat.isFile()) {
34
- // Worktree — .git is a file: "gitdir: /path/to/actual/git/dir"
35
- try {
36
- const content = readFileSync(dotGit, 'utf-8').trim();
37
- const match = content.match(/^gitdir:\s*(.+)$/);
38
- if (match) {
39
- const gitdir = match[1].trim();
40
- // Resolve relative paths against the project root
41
- const resolved = path.isAbsolute(gitdir)
42
- ? gitdir
43
- : path.resolve(projectRoot, gitdir);
44
- if (existsSync(resolved)) {
45
- return resolved;
46
- }
47
- }
48
- } catch {
49
- // Unreadable .git file — fall through
50
- }
51
- }
52
-
53
- return null;
54
- }
55
-
56
- /**
57
- * Resolve the hooks directory for a project (read-only — does NOT create directories).
58
- * Handles both normal repos and git worktrees.
59
- *
60
- * @param projectRoot - The root of the working tree
61
- * @returns Object with hooksDir and hookPath for post-commit, or null if no .git found
62
- */
63
- export function resolveHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
64
- const gitDir = resolveGitDir(projectRoot);
65
- if (!gitDir) return null;
66
-
67
- const hooksDir = path.join(gitDir, 'hooks');
68
-
69
- return {
70
- hooksDir,
71
- hookPath: path.join(hooksDir, 'post-commit'),
72
- };
73
- }
74
-
75
- /**
76
- * Resolve hooks directory AND ensure it exists on disk.
77
- * Use this only in write paths (hook install / autoHook).
78
- */
79
- export function ensureHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
80
- const resolved = resolveHooksDir(projectRoot);
81
- if (!resolved) return null;
82
-
83
- mkdirSync(resolved.hooksDir, { recursive: true });
84
- return resolved;
85
- }
1
+ /**
2
+ * Git hooks path resolver — worktree-safe.
3
+ *
4
+ * In a normal repo: .git is a directory → hooks at .git/hooks/
5
+ * In a git worktree: .git is a FILE containing "gitdir: /path/to/actual/git/dir"
6
+ * → hooks at that resolved path + /hooks/
7
+ *
8
+ * This utility ensures all hook install/uninstall/check operations
9
+ * work correctly in both scenarios.
10
+ */
11
+
12
+ import { existsSync, readFileSync, statSync, mkdirSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ /**
16
+ * Resolve the actual .git directory path, following worktree indirection.
17
+ *
18
+ * @param projectRoot - The root of the working tree (where .git lives)
19
+ * @returns Absolute path to the real git dir, or null if no .git found
20
+ */
21
+ export function resolveGitDir(projectRoot: string): string | null {
22
+ const dotGit = path.join(projectRoot, '.git');
23
+
24
+ if (!existsSync(dotGit)) return null;
25
+
26
+ const stat = statSync(dotGit);
27
+
28
+ if (stat.isDirectory()) {
29
+ // Normal repo — .git is a directory
30
+ return dotGit;
31
+ }
32
+
33
+ if (stat.isFile()) {
34
+ // Worktree — .git is a file: "gitdir: /path/to/actual/git/dir"
35
+ try {
36
+ const content = readFileSync(dotGit, 'utf-8').trim();
37
+ const match = content.match(/^gitdir:\s*(.+)$/);
38
+ if (match) {
39
+ const gitdir = match[1].trim();
40
+ // Resolve relative paths against the project root
41
+ const resolved = path.isAbsolute(gitdir)
42
+ ? gitdir
43
+ : path.resolve(projectRoot, gitdir);
44
+ if (existsSync(resolved)) {
45
+ return resolved;
46
+ }
47
+ }
48
+ } catch {
49
+ // Unreadable .git file — fall through
50
+ }
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Resolve the hooks directory for a project (read-only — does NOT create directories).
58
+ * Handles both normal repos and git worktrees.
59
+ *
60
+ * @param projectRoot - The root of the working tree
61
+ * @returns Object with hooksDir and hookPath for post-commit, or null if no .git found
62
+ */
63
+ export function resolveHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
64
+ const gitDir = resolveGitDir(projectRoot);
65
+ if (!gitDir) return null;
66
+
67
+ const hooksDir = path.join(gitDir, 'hooks');
68
+
69
+ return {
70
+ hooksDir,
71
+ hookPath: path.join(hooksDir, 'post-commit'),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Resolve hooks directory AND ensure it exists on disk.
77
+ * Use this only in write paths (hook install / autoHook).
78
+ */
79
+ export function ensureHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
80
+ const resolved = resolveHooksDir(projectRoot);
81
+ if (!resolved) return null;
82
+
83
+ mkdirSync(resolved.hooksDir, { recursive: true });
84
+ return resolved;
85
+ }