gitnexus 1.6.3-rc.9 → 1.6.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.
Files changed (257) hide show
  1. package/README.md +21 -5
  2. package/dist/_shared/graph/types.d.ts +16 -0
  3. package/dist/_shared/graph/types.d.ts.map +1 -1
  4. package/dist/_shared/index.d.ts +4 -2
  5. package/dist/_shared/index.d.ts.map +1 -1
  6. package/dist/_shared/index.js +2 -0
  7. package/dist/_shared/index.js.map +1 -1
  8. package/dist/_shared/scope-resolution/def-index.js +2 -2
  9. package/dist/_shared/scope-resolution/def-index.js.map +1 -1
  10. package/dist/_shared/scope-resolution/method-dispatch-index.d.ts +8 -0
  11. package/dist/_shared/scope-resolution/method-dispatch-index.d.ts.map +1 -1
  12. package/dist/_shared/scope-resolution/method-dispatch-index.js +2 -2
  13. package/dist/_shared/scope-resolution/method-dispatch-index.js.map +1 -1
  14. package/dist/_shared/scope-resolution/module-scope-index.d.ts +8 -0
  15. package/dist/_shared/scope-resolution/module-scope-index.d.ts.map +1 -1
  16. package/dist/_shared/scope-resolution/module-scope-index.js +10 -2
  17. package/dist/_shared/scope-resolution/module-scope-index.js.map +1 -1
  18. package/dist/_shared/scope-resolution/parsed-file.d.ts +76 -0
  19. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -0
  20. package/dist/_shared/scope-resolution/parsed-file.js +54 -0
  21. package/dist/_shared/scope-resolution/parsed-file.js.map +1 -0
  22. package/dist/_shared/scope-resolution/position-index.d.ts +12 -0
  23. package/dist/_shared/scope-resolution/position-index.d.ts.map +1 -1
  24. package/dist/_shared/scope-resolution/position-index.js +2 -2
  25. package/dist/_shared/scope-resolution/position-index.js.map +1 -1
  26. package/dist/_shared/scope-resolution/qualified-name-index.js +2 -2
  27. package/dist/_shared/scope-resolution/qualified-name-index.js.map +1 -1
  28. package/dist/_shared/scope-resolution/reference-site.d.ts +75 -0
  29. package/dist/_shared/scope-resolution/reference-site.d.ts.map +1 -0
  30. package/dist/_shared/scope-resolution/reference-site.js +24 -0
  31. package/dist/_shared/scope-resolution/reference-site.js.map +1 -0
  32. package/dist/_shared/scope-resolution/registries/evidence.js +5 -0
  33. package/dist/_shared/scope-resolution/registries/evidence.js.map +1 -1
  34. package/dist/_shared/scope-resolution/registries/lookup-core.js +21 -5
  35. package/dist/_shared/scope-resolution/registries/lookup-core.js.map +1 -1
  36. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts +1 -10
  37. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts.map +1 -1
  38. package/dist/_shared/scope-resolution/resolve-type-ref.js +6 -0
  39. package/dist/_shared/scope-resolution/resolve-type-ref.js.map +1 -1
  40. package/dist/_shared/scope-resolution/scope-tree.d.ts +4 -4
  41. package/dist/_shared/scope-resolution/scope-tree.d.ts.map +1 -1
  42. package/dist/_shared/scope-resolution/scope-tree.js +3 -2
  43. package/dist/_shared/scope-resolution/scope-tree.js.map +1 -1
  44. package/dist/_shared/scope-resolution/shadow/aggregate.d.ts +6 -2
  45. package/dist/_shared/scope-resolution/shadow/aggregate.d.ts.map +1 -1
  46. package/dist/_shared/scope-resolution/shadow/aggregate.js +5 -0
  47. package/dist/_shared/scope-resolution/shadow/aggregate.js.map +1 -1
  48. package/dist/_shared/scope-resolution/types.d.ts +11 -0
  49. package/dist/_shared/scope-resolution/types.d.ts.map +1 -1
  50. package/dist/cli/ai-context.js +35 -4
  51. package/dist/cli/analyze.d.ts +27 -0
  52. package/dist/cli/analyze.js +31 -1
  53. package/dist/cli/clean.js +19 -1
  54. package/dist/cli/group.js +73 -0
  55. package/dist/cli/index-repo.js +8 -1
  56. package/dist/cli/index.js +26 -1
  57. package/dist/cli/list.js +11 -1
  58. package/dist/cli/remove.d.ts +30 -0
  59. package/dist/cli/remove.js +99 -0
  60. package/dist/cli/setup.js +185 -57
  61. package/dist/cli/tool.d.ts +5 -0
  62. package/dist/cli/tool.js +42 -0
  63. package/dist/config/ignore-service.d.ts +9 -0
  64. package/dist/config/ignore-service.js +80 -13
  65. package/dist/core/embedding-mode.d.ts +30 -0
  66. package/dist/core/embedding-mode.js +30 -0
  67. package/dist/core/embeddings/ast-utils.js +22 -22
  68. package/dist/core/embeddings/chunker.js +30 -25
  69. package/dist/core/embeddings/embedding-pipeline.d.ts +6 -0
  70. package/dist/core/embeddings/embedding-pipeline.js +15 -6
  71. package/dist/core/embeddings/text-generator.d.ts +1 -1
  72. package/dist/core/embeddings/text-generator.js +33 -24
  73. package/dist/core/embeddings/types.d.ts +43 -1
  74. package/dist/core/embeddings/types.js +101 -29
  75. package/dist/core/git-staleness.d.ts +18 -0
  76. package/dist/core/git-staleness.js +108 -0
  77. package/dist/core/graph/graph.js +115 -20
  78. package/dist/core/graph/types.d.ts +12 -1
  79. package/dist/core/group/config-parser.d.ts +4 -0
  80. package/dist/core/group/config-parser.js +18 -1
  81. package/dist/core/group/cross-impact.d.ts +41 -0
  82. package/dist/core/group/cross-impact.js +441 -0
  83. package/dist/core/group/extractors/http-patterns/php.js +126 -18
  84. package/dist/core/group/group-path-utils.d.ts +17 -0
  85. package/dist/core/group/group-path-utils.js +40 -0
  86. package/dist/core/group/resolve-at-member.d.ts +10 -0
  87. package/dist/core/group/resolve-at-member.js +31 -0
  88. package/dist/core/group/service.d.ts +9 -0
  89. package/dist/core/group/service.js +259 -25
  90. package/dist/core/group/types.d.ts +30 -0
  91. package/dist/core/ingestion/ast-cache.d.ts +16 -1
  92. package/dist/core/ingestion/ast-cache.js +14 -2
  93. package/dist/core/ingestion/call-processor.js +9 -0
  94. package/dist/core/ingestion/emit-references.d.ts +88 -0
  95. package/dist/core/ingestion/emit-references.js +229 -0
  96. package/dist/core/ingestion/filesystem-walker.js +6 -4
  97. package/dist/core/ingestion/finalize-orchestrator.d.ts +63 -0
  98. package/dist/core/ingestion/finalize-orchestrator.js +139 -0
  99. package/dist/core/ingestion/framework-detection.js +6 -2
  100. package/dist/core/ingestion/import-processor.js +4 -0
  101. package/dist/core/ingestion/import-resolvers/python.js +9 -6
  102. package/dist/core/ingestion/import-target-adapter.d.ts +73 -0
  103. package/dist/core/ingestion/import-target-adapter.js +95 -0
  104. package/dist/core/ingestion/language-provider.d.ts +36 -33
  105. package/dist/core/ingestion/languages/csharp/accessor-unwrap.d.ts +21 -0
  106. package/dist/core/ingestion/languages/csharp/accessor-unwrap.js +56 -0
  107. package/dist/core/ingestion/languages/csharp/arity-metadata.d.ts +26 -0
  108. package/dist/core/ingestion/languages/csharp/arity-metadata.js +46 -0
  109. package/dist/core/ingestion/languages/csharp/arity.d.ts +23 -0
  110. package/dist/core/ingestion/languages/csharp/arity.js +37 -0
  111. package/dist/core/ingestion/languages/csharp/cache-stats.d.ts +15 -0
  112. package/dist/core/ingestion/languages/csharp/cache-stats.js +26 -0
  113. package/dist/core/ingestion/languages/csharp/captures.d.ts +19 -0
  114. package/dist/core/ingestion/languages/csharp/captures.js +249 -0
  115. package/dist/core/ingestion/languages/csharp/import-decomposer.d.ts +19 -0
  116. package/dist/core/ingestion/languages/csharp/import-decomposer.js +93 -0
  117. package/dist/core/ingestion/languages/csharp/import-target.d.ts +25 -0
  118. package/dist/core/ingestion/languages/csharp/import-target.js +123 -0
  119. package/dist/core/ingestion/languages/csharp/index.d.ts +82 -0
  120. package/dist/core/ingestion/languages/csharp/index.js +82 -0
  121. package/dist/core/ingestion/languages/csharp/interpret.d.ts +15 -0
  122. package/dist/core/ingestion/languages/csharp/interpret.js +132 -0
  123. package/dist/core/ingestion/languages/csharp/merge-bindings.d.ts +27 -0
  124. package/dist/core/ingestion/languages/csharp/merge-bindings.js +55 -0
  125. package/dist/core/ingestion/languages/csharp/namespace-siblings.d.ts +50 -0
  126. package/dist/core/ingestion/languages/csharp/namespace-siblings.js +374 -0
  127. package/dist/core/ingestion/languages/csharp/query.d.ts +35 -0
  128. package/dist/core/ingestion/languages/csharp/query.js +515 -0
  129. package/dist/core/ingestion/languages/csharp/receiver-binding.d.ts +31 -0
  130. package/dist/core/ingestion/languages/csharp/receiver-binding.js +135 -0
  131. package/dist/core/ingestion/languages/csharp/scope-resolver.d.ts +10 -0
  132. package/dist/core/ingestion/languages/csharp/scope-resolver.js +63 -0
  133. package/dist/core/ingestion/languages/csharp/simple-hooks.d.ts +53 -0
  134. package/dist/core/ingestion/languages/csharp/simple-hooks.js +76 -0
  135. package/dist/core/ingestion/languages/csharp.js +14 -0
  136. package/dist/core/ingestion/languages/python/arity-metadata.d.ts +24 -0
  137. package/dist/core/ingestion/languages/python/arity-metadata.js +45 -0
  138. package/dist/core/ingestion/languages/python/arity.d.ts +22 -0
  139. package/dist/core/ingestion/languages/python/arity.js +38 -0
  140. package/dist/core/ingestion/languages/python/cache-stats.d.ts +17 -0
  141. package/dist/core/ingestion/languages/python/cache-stats.js +28 -0
  142. package/dist/core/ingestion/languages/python/captures.d.ts +19 -0
  143. package/dist/core/ingestion/languages/python/captures.js +106 -0
  144. package/dist/core/ingestion/languages/python/import-decomposer.d.ts +15 -0
  145. package/dist/core/ingestion/languages/python/import-decomposer.js +112 -0
  146. package/dist/core/ingestion/languages/python/import-target.d.ts +21 -0
  147. package/dist/core/ingestion/languages/python/import-target.js +99 -0
  148. package/dist/core/ingestion/languages/python/index.d.ts +80 -0
  149. package/dist/core/ingestion/languages/python/index.js +80 -0
  150. package/dist/core/ingestion/languages/python/interpret.d.ts +15 -0
  151. package/dist/core/ingestion/languages/python/interpret.js +191 -0
  152. package/dist/core/ingestion/languages/python/merge-bindings.d.ts +16 -0
  153. package/dist/core/ingestion/languages/python/merge-bindings.js +44 -0
  154. package/dist/core/ingestion/languages/python/query.d.ts +9 -0
  155. package/dist/core/ingestion/languages/python/query.js +267 -0
  156. package/dist/core/ingestion/languages/python/receiver-binding.d.ts +21 -0
  157. package/dist/core/ingestion/languages/python/receiver-binding.js +116 -0
  158. package/dist/core/ingestion/languages/python/scope-resolver.d.ts +16 -0
  159. package/dist/core/ingestion/languages/python/scope-resolver.js +53 -0
  160. package/dist/core/ingestion/languages/python/simple-hooks.d.ts +23 -0
  161. package/dist/core/ingestion/languages/python/simple-hooks.js +35 -0
  162. package/dist/core/ingestion/languages/python.js +14 -0
  163. package/dist/core/ingestion/model/method-registry.d.ts +9 -0
  164. package/dist/core/ingestion/model/method-registry.js +4 -0
  165. package/dist/core/ingestion/model/scope-resolution-indexes.d.ts +59 -0
  166. package/dist/core/ingestion/model/scope-resolution-indexes.js +42 -0
  167. package/dist/core/ingestion/model/semantic-model.d.ts +64 -0
  168. package/dist/core/ingestion/model/semantic-model.js +55 -0
  169. package/dist/core/ingestion/mro-processor.js +38 -22
  170. package/dist/core/ingestion/parsing-processor.d.ts +18 -1
  171. package/dist/core/ingestion/parsing-processor.js +45 -11
  172. package/dist/core/ingestion/pipeline-phases/index.d.ts +1 -0
  173. package/dist/core/ingestion/pipeline-phases/index.js +1 -0
  174. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +10 -0
  175. package/dist/core/ingestion/pipeline-phases/parse-impl.js +17 -2
  176. package/dist/core/ingestion/pipeline-phases/parse.d.ts +18 -0
  177. package/dist/core/ingestion/pipeline.js +2 -1
  178. package/dist/core/ingestion/registry-primary-flag.d.ts +86 -0
  179. package/dist/core/ingestion/registry-primary-flag.js +111 -0
  180. package/dist/core/ingestion/resolve-references.d.ts +63 -0
  181. package/dist/core/ingestion/resolve-references.js +175 -0
  182. package/dist/core/ingestion/scope-extractor-bridge.d.ts +32 -0
  183. package/dist/core/ingestion/scope-extractor-bridge.js +44 -0
  184. package/dist/core/ingestion/scope-extractor.d.ts +86 -0
  185. package/dist/core/ingestion/scope-extractor.js +758 -0
  186. package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +372 -0
  187. package/dist/core/ingestion/scope-resolution/contract/scope-resolver.js +212 -0
  188. package/dist/core/ingestion/scope-resolution/graph-bridge/edges.d.ts +43 -0
  189. package/dist/core/ingestion/scope-resolution/graph-bridge/edges.js +79 -0
  190. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.d.ts +57 -0
  191. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +112 -0
  192. package/dist/core/ingestion/scope-resolution/graph-bridge/imports-to-edges.d.ts +17 -0
  193. package/dist/core/ingestion/scope-resolution/graph-bridge/imports-to-edges.js +46 -0
  194. package/dist/core/ingestion/scope-resolution/graph-bridge/method-dispatch.d.ts +19 -0
  195. package/dist/core/ingestion/scope-resolution/graph-bridge/method-dispatch.js +30 -0
  196. package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.d.ts +37 -0
  197. package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +113 -0
  198. package/dist/core/ingestion/scope-resolution/graph-bridge/references-to-edges.d.ts +38 -0
  199. package/dist/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js +73 -0
  200. package/dist/core/ingestion/scope-resolution/passes/compound-receiver.d.ts +42 -0
  201. package/dist/core/ingestion/scope-resolution/passes/compound-receiver.js +198 -0
  202. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.d.ts +27 -0
  203. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +131 -0
  204. package/dist/core/ingestion/scope-resolution/passes/imported-return-types.d.ts +48 -0
  205. package/dist/core/ingestion/scope-resolution/passes/imported-return-types.js +130 -0
  206. package/dist/core/ingestion/scope-resolution/passes/mro.d.ts +42 -0
  207. package/dist/core/ingestion/scope-resolution/passes/mro.js +99 -0
  208. package/dist/core/ingestion/scope-resolution/passes/overload-narrowing.d.ts +26 -0
  209. package/dist/core/ingestion/scope-resolution/passes/overload-narrowing.js +61 -0
  210. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.d.ts +46 -0
  211. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +327 -0
  212. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +47 -0
  213. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +130 -0
  214. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.d.ts +68 -0
  215. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +125 -0
  216. package/dist/core/ingestion/scope-resolution/pipeline/registry.d.ts +17 -0
  217. package/dist/core/ingestion/scope-resolution/pipeline/registry.js +21 -0
  218. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +66 -0
  219. package/dist/core/ingestion/scope-resolution/pipeline/run.js +157 -0
  220. package/dist/core/ingestion/scope-resolution/scope/namespace-targets.d.ts +36 -0
  221. package/dist/core/ingestion/scope-resolution/scope/namespace-targets.js +52 -0
  222. package/dist/core/ingestion/scope-resolution/scope/walkers.d.ts +127 -0
  223. package/dist/core/ingestion/scope-resolution/scope/walkers.js +349 -0
  224. package/dist/core/ingestion/scope-resolution/workspace-index.d.ts +52 -0
  225. package/dist/core/ingestion/scope-resolution/workspace-index.js +61 -0
  226. package/dist/core/ingestion/shadow-harness.d.ts +113 -0
  227. package/dist/core/ingestion/shadow-harness.js +148 -0
  228. package/dist/core/ingestion/utils/ast-helpers.d.ts +19 -1
  229. package/dist/core/ingestion/utils/ast-helpers.js +70 -0
  230. package/dist/core/ingestion/utils/max-file-size.d.ts +20 -0
  231. package/dist/core/ingestion/utils/max-file-size.js +52 -0
  232. package/dist/core/ingestion/workers/parse-worker.d.ts +9 -0
  233. package/dist/core/ingestion/workers/parse-worker.js +57 -21
  234. package/dist/core/lbug/lbug-adapter.d.ts +22 -2
  235. package/dist/core/lbug/lbug-adapter.js +58 -14
  236. package/dist/core/lbug/pool-adapter.d.ts +17 -0
  237. package/dist/core/lbug/pool-adapter.js +24 -14
  238. package/dist/core/run-analyze.d.ts +32 -0
  239. package/dist/core/run-analyze.js +74 -19
  240. package/dist/core/search/bm25-index.d.ts +18 -0
  241. package/dist/core/search/bm25-index.js +125 -12
  242. package/dist/core/tree-sitter/parser-loader.js +6 -1
  243. package/dist/mcp/local/local-backend.d.ts +67 -3
  244. package/dist/mcp/local/local-backend.js +296 -34
  245. package/dist/mcp/resources.d.ts +31 -0
  246. package/dist/mcp/resources.js +100 -17
  247. package/dist/mcp/tools.d.ts +4 -1
  248. package/dist/mcp/tools.js +75 -54
  249. package/dist/server/api.js +6 -2
  250. package/dist/storage/git.d.ts +49 -0
  251. package/dist/storage/git.js +111 -0
  252. package/dist/storage/repo-manager.d.ts +246 -1
  253. package/dist/storage/repo-manager.js +391 -9
  254. package/package.json +7 -6
  255. package/scripts/bench-scope-resolution.ts +134 -0
  256. package/scripts/ci-list-migrated-languages.ts +24 -0
  257. package/skills/gitnexus-cli.md +1 -0
package/dist/cli/group.js CHANGED
@@ -149,6 +149,79 @@ export function registerGroupCommands(program) {
149
149
  console.log(`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`);
150
150
  }
151
151
  });
152
+ group
153
+ .command('impact <name>')
154
+ .description('Cross-repo impact for a symbol in one member repo of a group')
155
+ .requiredOption('--target <symbol>', 'Symbol or file name to analyze')
156
+ .requiredOption('--repo <groupPath>', 'Member path from group.yaml (e.g. app/backend), not the indexed repo name')
157
+ .option('--direction <dir>', 'upstream or downstream', 'upstream')
158
+ .option('--service <path>', 'Optional monorepo service directory prefix (path filter)')
159
+ .option('--subgroup <path>', 'Optional prefix limiting which group repos participate in cross fan-out')
160
+ .option('--max-depth <n>', 'Max graph traversal depth')
161
+ .option('--cross-depth <n>', 'Cross-repository hop depth')
162
+ .option('--min-confidence <n>', 'Minimum relation confidence (0–1)')
163
+ .option('--include-tests', 'Include test files in traversal', false)
164
+ .option('--timeout-ms <n>', 'Phase-1 local impact wall time in milliseconds')
165
+ .option('--json', 'JSON output')
166
+ .action(async (name, opts) => {
167
+ const { LocalBackend } = await import('../mcp/local/local-backend.js');
168
+ const backend = new LocalBackend();
169
+ try {
170
+ await backend.init();
171
+ const payload = {
172
+ name,
173
+ repo: opts.repo,
174
+ target: opts.target,
175
+ direction: opts.direction || 'upstream',
176
+ };
177
+ if (opts.service)
178
+ payload.service = opts.service;
179
+ if (opts.subgroup)
180
+ payload.subgroup = opts.subgroup;
181
+ if (opts.maxDepth !== undefined && opts.maxDepth !== '') {
182
+ const n = parseInt(String(opts.maxDepth), 10);
183
+ if (!Number.isNaN(n))
184
+ payload.maxDepth = n;
185
+ }
186
+ if (opts.crossDepth !== undefined && opts.crossDepth !== '') {
187
+ const n = parseInt(String(opts.crossDepth), 10);
188
+ if (!Number.isNaN(n))
189
+ payload.crossDepth = n;
190
+ }
191
+ if (opts.minConfidence !== undefined && opts.minConfidence !== '') {
192
+ const n = parseFloat(String(opts.minConfidence));
193
+ if (!Number.isNaN(n))
194
+ payload.minConfidence = n;
195
+ }
196
+ if (opts.timeoutMs !== undefined && opts.timeoutMs !== '') {
197
+ const n = parseInt(String(opts.timeoutMs), 10);
198
+ if (!Number.isNaN(n))
199
+ payload.timeoutMs = n;
200
+ }
201
+ if (opts.includeTests)
202
+ payload.includeTests = true;
203
+ const raw = await backend.getGroupService().groupImpact(payload);
204
+ if (raw && typeof raw === 'object' && 'error' in raw) {
205
+ console.error(String(raw.error));
206
+ process.exitCode = 1;
207
+ return;
208
+ }
209
+ if (opts.json) {
210
+ console.log(JSON.stringify(raw, null, 2));
211
+ }
212
+ else {
213
+ const summary = raw?.summary;
214
+ const risk = raw?.risk;
215
+ console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}`);
216
+ if (summary) {
217
+ console.log(` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}`);
218
+ }
219
+ }
220
+ }
221
+ finally {
222
+ await backend.dispose().catch(() => { });
223
+ }
224
+ });
152
225
  group
153
226
  .command('query <name> <query>')
154
227
  .description('Search execution flows across all repos in a group')
@@ -11,7 +11,7 @@
11
11
  import path from 'path';
12
12
  import fs from 'fs/promises';
13
13
  import { getStoragePaths, loadMeta, addToGitignore, registerRepo, } from '../storage/repo-manager.js';
14
- import { getGitRoot, isGitRepo } from '../storage/git.js';
14
+ import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
15
15
  export const indexCommand = async (inputPathParts, options) => {
16
16
  console.log('\n GitNexus Index\n');
17
17
  const inputPath = inputPathParts?.length ? inputPathParts.join(' ') : undefined;
@@ -88,6 +88,13 @@ export const indexCommand = async (inputPathParts, options) => {
88
88
  };
89
89
  }
90
90
  // ── Register in global registry ───────────────────────────────────
91
+ // Refresh the on-disk meta with a freshly captured `remoteUrl` if
92
+ // it's missing, so an `index` of an older `.gitnexus/` still gets
93
+ // sibling-clone fingerprinting on subsequent use without forcing a
94
+ // full re-analyze.
95
+ if (!meta.remoteUrl && isGitRepo(repoPath)) {
96
+ meta.remoteUrl = getRemoteUrl(repoPath);
97
+ }
91
98
  await registerRepo(repoPath, meta);
92
99
  await addToGitignore(repoPath);
93
100
  const projectName = path.basename(repoPath);
package/dist/cli/index.js CHANGED
@@ -18,12 +18,23 @@ program
18
18
  .description('Index a repository (full analysis)')
19
19
  .option('-f, --force', 'Force full re-index even if up to date')
20
20
  .option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
21
+ .option('--drop-embeddings', 'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
22
+ 'preserves any embeddings already present in the index.')
21
23
  .option('--skills', 'Generate repo-specific skill files from detected communities')
22
24
  .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
23
25
  .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
24
26
  .option('--skip-git', 'Index a folder without requiring a .git directory')
27
+ .option('--name <alias>', 'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
28
+ '(disambiguates repos whose paths share a basename, e.g. two different .../app folders)')
29
+ .option('--allow-duplicate-name', 'Register this repo even if another path already uses the same --name alias. ' +
30
+ 'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.')
25
31
  .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
26
- .addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)')
32
+ .option('--max-file-size <kb>', 'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).')
33
+ .addHelpText('after', '\nEnvironment variables:\n' +
34
+ ' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
35
+ ' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
36
+ '\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
37
+ ' `!__tests__/` to index a directory that is auto-filtered by default (#771).')
27
38
  .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
28
39
  program
29
40
  .command('index [path...]')
@@ -55,6 +66,12 @@ program
55
66
  .option('-f, --force', 'Skip confirmation prompt')
56
67
  .option('--all', 'Clean all indexed repos')
57
68
  .action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
69
+ program
70
+ .command('remove <target>')
71
+ .description('Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' +
72
+ 'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.')
73
+ .option('-f, --force', 'Skip confirmation prompt')
74
+ .action(createLazyAction(() => import('./remove.js'), 'removeCommand'));
58
75
  program
59
76
  .command('wiki [path]')
60
77
  .description('Generate repository wiki from knowledge graph')
@@ -107,6 +124,14 @@ program
107
124
  .description('Execute raw Cypher query against the knowledge graph')
108
125
  .option('-r, --repo <name>', 'Target repository')
109
126
  .action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
127
+ program
128
+ .command('detect-changes')
129
+ .alias('detect_changes')
130
+ .description('Map git diff hunks to indexed symbols and affected execution flows')
131
+ .option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
132
+ .option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
133
+ .option('-r, --repo <name>', 'Target repository')
134
+ .action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
110
135
  // ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
111
136
  program
112
137
  .command('eval-server')
package/dist/cli/list.js CHANGED
@@ -12,11 +12,21 @@ export const listCommand = async () => {
12
12
  return;
13
13
  }
14
14
  console.log(`\n Indexed Repositories (${entries.length})\n`);
15
+ // Count occurrences of each name so colliding entries can be
16
+ // disambiguated in the header (#829). Unique-name entries render
17
+ // identically to pre-#829 output; only collisions gain a suffix.
18
+ const nameCounts = new Map();
19
+ for (const e of entries) {
20
+ const key = e.name.toLowerCase();
21
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
22
+ }
15
23
  for (const entry of entries) {
16
24
  const indexedDate = new Date(entry.indexedAt).toLocaleString();
17
25
  const stats = entry.stats || {};
18
26
  const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown';
19
- console.log(` ${entry.name}`);
27
+ const hasCollision = (nameCounts.get(entry.name.toLowerCase()) ?? 0) > 1;
28
+ const header = hasCollision ? `${entry.name} (${entry.path})` : entry.name;
29
+ console.log(` ${header}`);
20
30
  console.log(` Path: ${entry.path}`);
21
31
  console.log(` Indexed: ${indexedDate}`);
22
32
  console.log(` Commit: ${commitShort}`);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Remove Command (#664)
3
+ *
4
+ * Delete the `.gitnexus/` index for a registered repo and unregister it
5
+ * from the global registry (~/.gitnexus/registry.json). The target is
6
+ * identified by alias / basename-derived name / remote-inferred name /
7
+ * absolute path — no `--repo` flag, just a positional argument so the
8
+ * destructive-command ergonomics match `clean` (which is also
9
+ * destructive but scoped to `process.cwd()`).
10
+ *
11
+ * Compared to `clean`:
12
+ * - `clean` acts on the repo discovered by walking up from cwd.
13
+ * - `remove` acts on any registered repo identified by name or path.
14
+ *
15
+ * Behaviour notes:
16
+ * - Idempotent on unknown targets: exits 0 with a warning so that
17
+ * `remove X && analyze Y` keeps working in scripts. Per #664:
18
+ * "behave atomically and idempotently so retries are safe".
19
+ * - Atomic order mirrors `clean`: fs.rm FIRST, then unregister. A
20
+ * partial failure leaves the registry pointing at a missing dir
21
+ * (recoverable by `listRegisteredRepos({ validate: true })` on
22
+ * next read) rather than the opposite, which would orphan
23
+ * .gitnexus/ directories on disk.
24
+ * - `-f` / `--force` matches the confirmation-skip semantics of
25
+ * `clean -f`. (Distinct from `analyze --force`, which re-indexes;
26
+ * here there is no pipeline, so no conflation.)
27
+ */
28
+ export declare const removeCommand: (target: string, options?: {
29
+ force?: boolean;
30
+ }) => Promise<void>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Remove Command (#664)
3
+ *
4
+ * Delete the `.gitnexus/` index for a registered repo and unregister it
5
+ * from the global registry (~/.gitnexus/registry.json). The target is
6
+ * identified by alias / basename-derived name / remote-inferred name /
7
+ * absolute path — no `--repo` flag, just a positional argument so the
8
+ * destructive-command ergonomics match `clean` (which is also
9
+ * destructive but scoped to `process.cwd()`).
10
+ *
11
+ * Compared to `clean`:
12
+ * - `clean` acts on the repo discovered by walking up from cwd.
13
+ * - `remove` acts on any registered repo identified by name or path.
14
+ *
15
+ * Behaviour notes:
16
+ * - Idempotent on unknown targets: exits 0 with a warning so that
17
+ * `remove X && analyze Y` keeps working in scripts. Per #664:
18
+ * "behave atomically and idempotently so retries are safe".
19
+ * - Atomic order mirrors `clean`: fs.rm FIRST, then unregister. A
20
+ * partial failure leaves the registry pointing at a missing dir
21
+ * (recoverable by `listRegisteredRepos({ validate: true })` on
22
+ * next read) rather than the opposite, which would orphan
23
+ * .gitnexus/ directories on disk.
24
+ * - `-f` / `--force` matches the confirmation-skip semantics of
25
+ * `clean -f`. (Distinct from `analyze --force`, which re-indexes;
26
+ * here there is no pipeline, so no conflation.)
27
+ */
28
+ import fs from 'fs/promises';
29
+ import { readRegistry, resolveRegistryEntry, assertSafeStoragePath, unregisterRepo, RegistryNotFoundError, RegistryAmbiguousTargetError, UnsafeStoragePathError, } from '../storage/repo-manager.js';
30
+ export const removeCommand = async (target, options) => {
31
+ // Read the registry snapshot once and pass it to the resolver — this
32
+ // lets us render the "before" state in the dry-run path without a
33
+ // second disk read.
34
+ const entries = await readRegistry();
35
+ let entry;
36
+ try {
37
+ entry = resolveRegistryEntry(entries, target);
38
+ }
39
+ catch (err) {
40
+ if (err instanceof RegistryNotFoundError) {
41
+ // Idempotent: missing target is a no-op warning, not an error.
42
+ // The `availableNames` hint comes from the error itself so users
43
+ // can see what they might have meant.
44
+ console.warn(`Nothing to remove: ${err.message}`);
45
+ return;
46
+ }
47
+ if (err instanceof RegistryAmbiguousTargetError) {
48
+ // Duplicate aliases are allowed via --allow-duplicate-name (#829);
49
+ // refuse to guess which one the user meant — surface the full list
50
+ // and exit non-zero so scripts don't silently pick the wrong repo.
51
+ console.error(`Error: ${err.message}`);
52
+ process.exit(1);
53
+ }
54
+ throw err;
55
+ }
56
+ // Confirmation gate — same shape as `clean`. Default is a dry-run
57
+ // that describes what would be deleted; `--force` actually deletes.
58
+ if (!options?.force) {
59
+ console.log(`This will delete the GitNexus index for: ${entry.name}`);
60
+ console.log(` Path: ${entry.path}`);
61
+ console.log(` Storage: ${entry.storagePath}`);
62
+ console.log('\nRun with --force to confirm deletion.');
63
+ return;
64
+ }
65
+ // Safety guard (#1003 review — @magyargergo): refuse to proceed if
66
+ // the registry entry's `storagePath` isn't the canonical
67
+ // `<entry.path>/.gitnexus` subfolder. `~/.gitnexus/registry.json` is
68
+ // user-writable, so a corrupted or hand-edited entry could point
69
+ // storagePath at the repo root, an empty string (→ cwd), a parent
70
+ // dir, or anywhere else; `fs.rm(recursive: true, force: true)` on
71
+ // any of those would be a runtime disaster. Bail before touching
72
+ // disk, with an actionable hint for recovering a broken registry.
73
+ try {
74
+ assertSafeStoragePath(entry);
75
+ }
76
+ catch (err) {
77
+ if (err instanceof UnsafeStoragePathError) {
78
+ console.error(`Error: ${err.message}`);
79
+ process.exit(1);
80
+ }
81
+ throw err;
82
+ }
83
+ // Deletion order: fs.rm first, then unregister. If fs.rm fails mid-way,
84
+ // the registry entry stays so the user can retry. If fs.rm succeeds but
85
+ // unregister throws (e.g. ENOSPC on registry write), the entry becomes
86
+ // orphaned — `listRegisteredRepos({ validate: true })` prunes those on
87
+ // next read, so the failure is self-healing.
88
+ try {
89
+ await fs.rm(entry.storagePath, { recursive: true, force: true });
90
+ await unregisterRepo(entry.path);
91
+ console.log(`Removed: ${entry.name}`);
92
+ console.log(` Path: ${entry.path}`);
93
+ console.log(` Storage: ${entry.storagePath}`);
94
+ }
95
+ catch (err) {
96
+ console.error(`Failed to remove ${entry.name}:`, err);
97
+ process.exit(1);
98
+ }
99
+ };
package/dist/cli/setup.js CHANGED
@@ -12,6 +12,7 @@ import { execFile, execFileSync } from 'child_process';
12
12
  import { promisify } from 'util';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { glob } from 'glob';
15
+ import { parseTree, modify, applyEdits, parse as parseJsonc } from 'jsonc-parser';
15
16
  import { getGlobalDir } from '../storage/repo-manager.js';
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = path.dirname(__filename);
@@ -64,37 +65,61 @@ function getMcpEntry() {
64
65
  };
65
66
  }
66
67
  /**
67
- * Merge gitnexus entry into an existing MCP config JSON object.
68
- * Returns the updated config.
68
+ * OpenCode uses a different MCP format: { type: "local", command: [...] }
69
+ * where command is a flat array (command + args combined).
69
70
  */
70
- function mergeMcpConfig(existing) {
71
- if (!existing || typeof existing !== 'object') {
72
- existing = {};
71
+ function getOpenCodeMcpEntry() {
72
+ const bin = resolveGitnexusBin();
73
+ if (bin) {
74
+ return { type: 'local', command: [bin, 'mcp'] };
73
75
  }
74
- if (!existing.mcpServers || typeof existing.mcpServers !== 'object') {
75
- existing.mcpServers = {};
76
+ if (process.platform === 'win32') {
77
+ return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
76
78
  }
77
- existing.mcpServers.gitnexus = getMcpEntry();
78
- return existing;
79
+ return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
79
80
  }
80
81
  /**
81
- * Try to read a JSON file, returning null if it doesn't exist or is invalid.
82
+ * Detect indentation style from file content.
83
+ * Returns formatting options matching the file's existing style.
82
84
  */
83
- async function readJsonFile(filePath) {
84
- try {
85
- const raw = await fs.readFile(filePath, 'utf-8');
86
- return JSON.parse(raw);
87
- }
88
- catch {
89
- return null;
90
- }
85
+ function detectIndentation(raw) {
86
+ const firstIndented = raw.match(/^( +|\t)/m);
87
+ if (!firstIndented)
88
+ return { tabSize: 2, insertSpaces: true };
89
+ if (firstIndented[1] === '\t')
90
+ return { tabSize: 1, insertSpaces: false };
91
+ return { tabSize: firstIndented[1].length, insertSpaces: true };
91
92
  }
92
93
  /**
93
- * Write JSON to a file, creating parent directories if needed.
94
+ * Merge a key/value pair into a JSONC config file, preserving comments and formatting.
95
+ * If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
94
96
  */
95
- async function writeJsonFile(filePath, data) {
96
- await fs.mkdir(path.dirname(filePath), { recursive: true });
97
- await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
97
+ async function mergeJsoncFile(filePath, keyPath, value) {
98
+ let raw;
99
+ try {
100
+ raw = await fs.readFile(filePath, 'utf-8');
101
+ }
102
+ catch {
103
+ raw = '';
104
+ }
105
+ if (raw.trim().length === 0) {
106
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
107
+ const formattingOptions = { tabSize: 2, insertSpaces: true };
108
+ const edits = modify('{}', keyPath, value, { formattingOptions });
109
+ const result = applyEdits('{}', edits);
110
+ await fs.writeFile(filePath, result, 'utf-8');
111
+ return true;
112
+ }
113
+ const parseErrors = [];
114
+ const tree = parseTree(raw, parseErrors);
115
+ if (tree && tree.type === 'object' && parseErrors.length === 0) {
116
+ const formattingOptions = detectIndentation(raw);
117
+ const edits = modify(raw, keyPath, value, { formattingOptions });
118
+ const result = applyEdits(raw, edits);
119
+ await fs.writeFile(filePath, result, 'utf-8');
120
+ return true;
121
+ }
122
+ return false;
98
123
  }
99
124
  /**
100
125
  * Check if a directory exists
@@ -117,10 +142,13 @@ async function setupCursor(result) {
117
142
  }
118
143
  const mcpPath = path.join(cursorDir, 'mcp.json');
119
144
  try {
120
- const existing = await readJsonFile(mcpPath);
121
- const updated = mergeMcpConfig(existing);
122
- await writeJsonFile(mcpPath, updated);
123
- result.configured.push('Cursor');
145
+ const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
146
+ if (ok) {
147
+ result.configured.push('Cursor');
148
+ }
149
+ else {
150
+ result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content');
151
+ }
124
152
  }
125
153
  catch (err) {
126
154
  result.errors.push(`Cursor: ${err.message}`);
@@ -135,10 +163,13 @@ async function setupClaudeCode(result) {
135
163
  // Claude Code stores MCP config in ~/.claude.json
136
164
  const mcpPath = path.join(os.homedir(), '.claude.json');
137
165
  try {
138
- const existing = await readJsonFile(mcpPath);
139
- const updated = mergeMcpConfig(existing);
140
- await writeJsonFile(mcpPath, updated);
141
- result.configured.push('Claude Code');
166
+ const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
167
+ if (ok) {
168
+ result.configured.push('Claude Code');
169
+ }
170
+ else {
171
+ result.errors.push('Claude Code: .claude.json is corrupt — skipping to preserve existing content');
172
+ }
142
173
  }
143
174
  catch (err) {
144
175
  result.errors.push(`Claude Code: ${err.message}`);
@@ -162,9 +193,71 @@ async function installClaudeCodeSkills(result) {
162
193
  result.errors.push(`Claude Code skills: ${err.message}`);
163
194
  }
164
195
  }
196
+ /**
197
+ * Check whether an event array already contains a gitnexus-hook entry.
198
+ */
199
+ function hasGitnexusHook(hooksObj, eventName) {
200
+ const entries = hooksObj?.[eventName];
201
+ if (!Array.isArray(entries))
202
+ return false;
203
+ return entries.some((h) => Array.isArray(h.hooks) &&
204
+ h.hooks.some((hh) => typeof hh.command === 'string' && hh.command.includes('gitnexus-hook')));
205
+ }
206
+ /**
207
+ * Merge hook entries into a JSONC settings file, preserving comments and formatting.
208
+ * Uses chained modify()+applyEdits() calls to append to arrays without a full
209
+ * JSON.stringify roundtrip that would strip comments.
210
+ */
211
+ async function mergeHooksJsonc(filePath, entries) {
212
+ let raw;
213
+ try {
214
+ raw = await fs.readFile(filePath, 'utf-8');
215
+ }
216
+ catch {
217
+ raw = '';
218
+ }
219
+ if (raw.trim().length === 0) {
220
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
221
+ const hooks = {};
222
+ for (const { eventName, value } of entries) {
223
+ hooks[eventName] = [value];
224
+ }
225
+ const formattingOptions = { tabSize: 2, insertSpaces: true };
226
+ const edits = modify('{}', ['hooks'], hooks, { formattingOptions });
227
+ await fs.writeFile(filePath, applyEdits('{}', edits), 'utf-8');
228
+ return true;
229
+ }
230
+ const parseErrors = [];
231
+ const tree = parseTree(raw, parseErrors);
232
+ if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
233
+ return false;
234
+ }
235
+ const formattingOptions = detectIndentation(raw);
236
+ let current = raw;
237
+ for (const { eventName, value } of entries) {
238
+ // Re-parse after each edit to get a fresh insertion index.
239
+ const currentTree = parseTree(current, []);
240
+ const hooksNode = currentTree?.children?.find((c) => c.type === 'property' && c.children?.[0]?.value === 'hooks');
241
+ const eventNode = hooksNode?.children?.[1]?.children?.find((c) => c.type === 'property' && c.children?.[0]?.value === eventName);
242
+ let insertIndex;
243
+ if (eventNode?.children?.[1] && Array.isArray(eventNode.children[1].children)) {
244
+ insertIndex = eventNode.children[1].children.length;
245
+ }
246
+ else {
247
+ insertIndex = 0;
248
+ }
249
+ const edits = modify(current, ['hooks', eventName, insertIndex], value, {
250
+ formattingOptions,
251
+ });
252
+ current = applyEdits(current, edits);
253
+ }
254
+ await fs.writeFile(filePath, current, 'utf-8');
255
+ return true;
256
+ }
165
257
  /**
166
258
  * Install GitNexus hooks to ~/.claude/settings.json for Claude Code.
167
- * Merges hook config without overwriting existing hooks.
259
+ * Merges hook config without overwriting existing hooks, preserving
260
+ * comments and formatting in the JSONC file.
168
261
  */
169
262
  async function installClaudeCodeHooks(result) {
170
263
  const claudeDir = path.join(os.homedir(), '.claude');
@@ -181,8 +274,6 @@ async function installClaudeCodeHooks(result) {
181
274
  const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
182
275
  try {
183
276
  let content = await fs.readFile(src, 'utf-8');
184
- // Inject resolved CLI path so the copied hook can find the CLI
185
- // even when it's no longer inside the npm package tree
186
277
  const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
187
278
  const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
188
279
  const jsonCli = JSON.stringify(normalizedCli);
@@ -194,25 +285,62 @@ async function installClaudeCodeHooks(result) {
194
285
  }
195
286
  const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
196
287
  const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
197
- // Merge hook config into ~/.claude/settings.json
198
- const existing = (await readJsonFile(settingsPath)) || {};
199
- if (!existing.hooks)
200
- existing.hooks = {};
201
- function ensureHookEntry(eventName, matcher, timeout, statusMessage) {
202
- if (!existing.hooks[eventName])
203
- existing.hooks[eventName] = [];
204
- const hasHook = existing.hooks[eventName].some((h) => h.hooks?.some((hh) => hh.command?.includes('gitnexus-hook')));
205
- if (!hasHook) {
206
- existing.hooks[eventName].push({
207
- matcher,
208
- hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }],
209
- });
288
+ // Check which hook events need entries (idempotent: skip if already registered)
289
+ const parsed = await (async () => {
290
+ try {
291
+ const r = await fs.readFile(settingsPath, 'utf-8');
292
+ return parseJsonc(r);
293
+ }
294
+ catch {
295
+ return null;
210
296
  }
297
+ })();
298
+ const hookEntries = [];
299
+ // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
300
+ // Session context is delivered via CLAUDE.md / skills instead.
301
+ if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
302
+ hookEntries.push({
303
+ eventName: 'PreToolUse',
304
+ value: {
305
+ matcher: 'Grep|Glob|Bash',
306
+ hooks: [
307
+ {
308
+ type: 'command',
309
+ command: hookCmd,
310
+ timeout: 10,
311
+ statusMessage: 'Enriching with GitNexus graph context...',
312
+ },
313
+ ],
314
+ },
315
+ });
316
+ }
317
+ if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
318
+ hookEntries.push({
319
+ eventName: 'PostToolUse',
320
+ value: {
321
+ matcher: 'Bash',
322
+ hooks: [
323
+ {
324
+ type: 'command',
325
+ command: hookCmd,
326
+ timeout: 10,
327
+ statusMessage: 'Checking GitNexus index freshness...',
328
+ },
329
+ ],
330
+ },
331
+ });
332
+ }
333
+ if (hookEntries.length === 0) {
334
+ result.configured.push('Claude Code hooks (already configured)');
335
+ return;
336
+ }
337
+ const ok = await mergeHooksJsonc(settingsPath, hookEntries);
338
+ if (ok) {
339
+ result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
340
+ }
341
+ else {
342
+ result.errors.push('Claude Code hooks: settings.json is corrupt — skipping to preserve existing content');
211
343
  }
212
- ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...');
213
- ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...');
214
- await writeJsonFile(settingsPath, existing);
215
- result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
216
344
  }
217
345
  catch (err) {
218
346
  result.errors.push(`Claude Code hooks: ${err.message}`);
@@ -226,13 +354,13 @@ async function setupOpenCode(result) {
226
354
  }
227
355
  const configPath = path.join(opencodeDir, 'opencode.json');
228
356
  try {
229
- const existing = await readJsonFile(configPath);
230
- const config = existing || {};
231
- if (!config.mcp)
232
- config.mcp = {};
233
- config.mcp.gitnexus = getMcpEntry();
234
- await writeJsonFile(configPath, config);
235
- result.configured.push('OpenCode');
357
+ const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
358
+ if (ok) {
359
+ result.configured.push('OpenCode');
360
+ }
361
+ else {
362
+ result.errors.push('OpenCode: opencode.json is corrupt — skipping to preserve existing content');
363
+ }
236
364
  }
237
365
  catch (err) {
238
366
  result.errors.push(`OpenCode: ${err.message}`);
@@ -36,3 +36,8 @@ export declare function impactCommand(target: string, options?: {
36
36
  export declare function cypherCommand(query: string, options?: {
37
37
  repo?: string;
38
38
  }): Promise<void>;
39
+ export declare function detectChangesCommand(options?: {
40
+ scope?: string;
41
+ baseRef?: string;
42
+ repo?: string;
43
+ }): Promise<void>;