ctxman 3.1.1 → 3.2.8

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 (55) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +109 -10
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-count.js +45 -6
  7. package/bin/cm-diagnose.js +199 -0
  8. package/bin/cm-focus.js +322 -0
  9. package/bin/cm-map.js +71 -18
  10. package/bin/cm-pack.js +36 -5
  11. package/bin/cm-policy.js +107 -2
  12. package/bin/cm-pr.js +18 -3
  13. package/bin/cm-search.js +84 -19
  14. package/bin/cm-task.js +192 -0
  15. package/bin/cm-who.js +119 -0
  16. package/bin/cm-why.js +41 -5
  17. package/index.d.ts +940 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/token-calculator.js +76 -4
  20. package/lib/api/mcp/server.js +843 -29
  21. package/lib/cache/CacheManager.js +161 -47
  22. package/lib/core/Analyzer.js +61 -5
  23. package/lib/count/index.js +74 -14
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/policy/audit.js +3 -1
  34. package/lib/policy/index.js +5 -0
  35. package/lib/policy/workspace.js +254 -0
  36. package/lib/pr/pr-context.js +292 -31
  37. package/lib/pr/pr-renderer.js +25 -10
  38. package/lib/rag/EmbeddingProvider.js +139 -2
  39. package/lib/rag/VectorStoreFactory.js +10 -4
  40. package/lib/rag/ask.js +186 -17
  41. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  42. package/lib/search/hybrid.js +324 -0
  43. package/lib/search/symbols.js +75 -11
  44. package/lib/task/index.js +630 -0
  45. package/lib/utils/file-utils.js +1 -0
  46. package/lib/utils/multi-repo-manager.js +4 -1
  47. package/lib/watch/FileWatcher.js +121 -31
  48. package/lib/watch/IncrementalAnalyzer.js +964 -16
  49. package/lib/who/index.js +893 -0
  50. package/lib/why/analyzer-bridge.js +223 -0
  51. package/lib/why/discussions.js +314 -0
  52. package/lib/why/index.js +356 -30
  53. package/lib/why/render.js +154 -9
  54. package/lib/workspace/index.js +1274 -0
  55. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * FileWatcher - Real-Time File Watching Module
3
- * v3.0.0 - Watch mode
3
+ * v3.0.0 - Watch mode (with Cross-Repo Multi-Root Workspace support)
4
4
  *
5
5
  * Responsibilities:
6
- * - Watch file system for changes
6
+ * - Watch file system for changes across one or more repositories
7
7
  * - Debounce rapid changes
8
- * - Emit change events
9
- * - Support ignore patterns
8
+ * - Emit change events (namespaced as <alias>/<relativePath> in multi-repo mode)
9
+ * - Support per-repository ignore patterns (.gitignore, .contextignore, .contextinclude)
10
10
  */
11
11
 
12
12
  import fs from 'fs';
@@ -14,6 +14,7 @@ import path from 'path';
14
14
  import { EventEmitter } from 'events';
15
15
  import GitIgnoreParser from '../parsers/gitignore-parser.js';
16
16
  import FileUtils from '../utils/file-utils.js';
17
+ import { resolveWorkspaceRepos } from '../workspace/index.js';
17
18
  import { getLogger } from '../utils/logger.js';
18
19
 
19
20
  const logger = getLogger('FileWatcher');
@@ -22,20 +23,29 @@ export class FileWatcher extends EventEmitter {
22
23
  constructor(rootPath, options = {}) {
23
24
  super();
24
25
 
25
- this.rootPath = rootPath;
26
+ this.rootPath = path.resolve(rootPath);
26
27
  this.options = {
27
28
  debounce: 1000, // 1 second debounce
28
29
  recursive: true,
29
30
  ignorePatterns: [],
31
+ repos: [],
30
32
  ...options,
31
33
  };
32
34
 
33
- // Initialize GitIgnoreParser with correct file paths
34
- const gitignorePath = path.join(rootPath, '.gitignore');
35
- const contextIgnorePath = path.join(rootPath, '.contextignore');
36
- const contextIncludePath = path.join(rootPath, '.contextinclude');
35
+ const extraRepos = Array.isArray(this.options.repos) ? this.options.repos.filter(Boolean) : [];
36
+ this.isMultiRepo = extraRepos.length > 0;
37
+ this.repos = this.isMultiRepo
38
+ ? resolveWorkspaceRepos([this.rootPath, ...extraRepos])
39
+ : [{ root: this.rootPath, alias: null }];
40
+
41
+ // Initialize GitIgnoreParser for primary root and per-repo parsers
42
+ this.gitIgnoreByRoot = new Map();
43
+ for (const repo of this.repos) {
44
+ this.gitIgnoreByRoot.set(repo.root, this._createGitIgnoreParser(repo.root));
45
+ }
46
+ this.gitIgnore =
47
+ this.gitIgnoreByRoot.get(this.rootPath) || this._createGitIgnoreParser(this.rootPath);
37
48
 
38
- this.gitIgnore = new GitIgnoreParser(gitignorePath, contextIgnorePath, contextIncludePath);
39
49
  this.watchers = [];
40
50
  this.debounceTimers = new Map();
41
51
  this.isWatching = false;
@@ -48,7 +58,19 @@ export class FileWatcher extends EventEmitter {
48
58
  }
49
59
 
50
60
  /**
51
- * Start watching
61
+ * Create a GitIgnoreParser for a repository root
62
+ * @param {string} repoRoot
63
+ * @returns {GitIgnoreParser}
64
+ */
65
+ _createGitIgnoreParser(repoRoot) {
66
+ const gitignorePath = path.join(repoRoot, '.gitignore');
67
+ const contextIgnorePath = path.join(repoRoot, '.contextignore');
68
+ const contextIncludePath = path.join(repoRoot, '.contextinclude');
69
+ return new GitIgnoreParser(gitignorePath, contextIgnorePath, contextIncludePath);
70
+ }
71
+
72
+ /**
73
+ * Start watching all configured repositories
52
74
  */
53
75
  start() {
54
76
  if (this.isWatching) {
@@ -56,23 +78,40 @@ export class FileWatcher extends EventEmitter {
56
78
  return;
57
79
  }
58
80
 
59
- logger.info(`Starting file watch: ${this.rootPath}`);
81
+ logger.info(
82
+ this.isMultiRepo
83
+ ? `Starting multi-repo file watch (${this.repos.map((r) => r.alias).join(', ')})`
84
+ : `Starting file watch: ${this.rootPath}`
85
+ );
60
86
 
61
87
  try {
62
- const watcher = fs.watch(
63
- this.rootPath,
64
- { recursive: this.options.recursive },
65
- (eventType, filename) => {
66
- this.handleFileChange(eventType, filename);
67
- }
68
- );
88
+ for (const repo of this.repos) {
89
+ const watcher = fs.watch(
90
+ repo.root,
91
+ { recursive: this.options.recursive },
92
+ (eventType, filename) => {
93
+ this.handleFileChange(eventType, filename, repo);
94
+ }
95
+ );
96
+ this.watchers.push(watcher);
97
+ }
69
98
 
70
- this.watchers.push(watcher);
71
99
  this.isWatching = true;
72
100
 
73
- this.emit('watch:started', { path: this.rootPath });
101
+ this.emit('watch:started', {
102
+ path: this.rootPath,
103
+ repos: this.repos.map((r) => ({ root: r.root, alias: r.alias })),
104
+ });
74
105
  logger.info('Watch mode started');
75
106
  } catch (error) {
107
+ for (const watcher of this.watchers) {
108
+ try {
109
+ watcher.close();
110
+ } catch {
111
+ // ignore cleanup errors
112
+ }
113
+ }
114
+ this.watchers = [];
76
115
  logger.error(`Failed to start watch: ${error.message}`);
77
116
  this.emit('watch:error', { error });
78
117
  throw error;
@@ -111,19 +150,26 @@ export class FileWatcher extends EventEmitter {
111
150
  * Handle file change event
112
151
  * @param {string} eventType - 'rename' or 'change'
113
152
  * @param {string} filename - Changed file name
153
+ * @param {Object} [repo] - Resolved repository entry { root, alias }
114
154
  */
115
- handleFileChange(eventType, filename) {
155
+ handleFileChange(eventType, filename, repo = null) {
116
156
  if (!filename) return;
117
157
 
118
- const filePath = path.join(this.rootPath, filename);
119
- const relativePath = path.relative(this.rootPath, filePath);
158
+ const targetRepo = repo || this.repos[0] || { root: this.rootPath, alias: null };
159
+ const filePath = path.join(targetRepo.root, filename);
160
+ const repoRelativePath = path.relative(targetRepo.root, filePath);
120
161
 
121
- // Check ignore patterns
122
- if (this.shouldIgnore(relativePath)) {
162
+ // Check ignore patterns against the owning repository
163
+ if (this.shouldIgnore(repoRelativePath, targetRepo.root)) {
123
164
  this.stats.ignoredChanges++;
124
165
  return;
125
166
  }
126
167
 
168
+ const relativePath =
169
+ this.isMultiRepo && targetRepo.alias
170
+ ? `${targetRepo.alias}/${repoRelativePath.split(path.sep).join('/')}`
171
+ : repoRelativePath;
172
+
127
173
  this.stats.totalChanges++;
128
174
 
129
175
  // Debounce rapid changes
@@ -133,7 +179,7 @@ export class FileWatcher extends EventEmitter {
133
179
 
134
180
  const timer = setTimeout(() => {
135
181
  this.debounceTimers.delete(filePath);
136
- this.emitFileChange(eventType, filePath, relativePath);
182
+ this.emitFileChange(eventType, filePath, relativePath, targetRepo, repoRelativePath);
137
183
  }, this.options.debounce);
138
184
 
139
185
  this.debounceTimers.set(filePath, timer);
@@ -144,8 +190,10 @@ export class FileWatcher extends EventEmitter {
144
190
  * @param {string} eventType
145
191
  * @param {string} filePath
146
192
  * @param {string} relativePath
193
+ * @param {Object} [repo]
194
+ * @param {string} [repoRelativePath]
147
195
  */
148
- emitFileChange(eventType, filePath, relativePath) {
196
+ emitFileChange(eventType, filePath, relativePath, repo = null, repoRelativePath = null) {
149
197
  logger.debug(`File ${eventType}: ${relativePath}`);
150
198
 
151
199
  const event = {
@@ -156,6 +204,12 @@ export class FileWatcher extends EventEmitter {
156
204
  exists: fs.existsSync(filePath),
157
205
  };
158
206
 
207
+ if (this.isMultiRepo && repo?.alias) {
208
+ event.repo = repo.alias;
209
+ event.repoRoot = repo.root;
210
+ event.repoRelativePath = repoRelativePath || relativePath;
211
+ }
212
+
159
213
  // Add file stats if exists
160
214
  if (event.exists) {
161
215
  try {
@@ -173,15 +227,50 @@ export class FileWatcher extends EventEmitter {
173
227
  /**
174
228
  * Check if file should be ignored
175
229
  * @param {string} relativePath
230
+ * @param {string} [repoRoot]
176
231
  * @returns {boolean}
177
232
  */
178
- shouldIgnore(relativePath) {
233
+ shouldIgnore(relativePath, repoRoot = this.rootPath) {
234
+ const parts = relativePath.split(/[/\\]/);
235
+ const normalized = parts.join('/');
236
+
179
237
  // node_modules, .git and build output are skipped like in the scanners, .gitignore or not
180
- if (relativePath.split(path.sep).some((part) => FileUtils.isSkippedDirectory(part))) {
238
+ if (parts.some((part) => FileUtils.isSkippedDirectory(part))) {
239
+ return true;
240
+ }
241
+
242
+ // Ignore .ctxman internal state (e.g. .ctxman/self-knowledge/index.sqlite) and SQLite journals
243
+ // so watch-mode RAG/graph writes never trigger a recursive watch feedback loop
244
+ if (
245
+ parts.includes('.ctxman') ||
246
+ normalized.endsWith('.sqlite') ||
247
+ normalized.endsWith('.sqlite-wal') ||
248
+ normalized.endsWith('.sqlite-shm') ||
249
+ normalized.endsWith('.sqlite-journal')
250
+ ) {
181
251
  return true;
182
252
  }
253
+
254
+ // Check custom ignorePatterns if configured
255
+ if (Array.isArray(this.options.ignorePatterns) && this.options.ignorePatterns.length > 0) {
256
+ for (const pattern of this.options.ignorePatterns) {
257
+ if (
258
+ typeof pattern === 'string' &&
259
+ (normalized === pattern || normalized.startsWith(`${pattern}/`))
260
+ ) {
261
+ return true;
262
+ }
263
+ if (pattern instanceof RegExp && pattern.test(normalized)) {
264
+ return true;
265
+ }
266
+ }
267
+ }
268
+
269
+ const resolvedRoot = repoRoot ? path.resolve(repoRoot) : this.rootPath;
270
+ const parser = this.gitIgnoreByRoot.get(resolvedRoot) || this.gitIgnore;
271
+
183
272
  // Use GitIgnoreParser for ignore rules
184
- return this.gitIgnore.isIgnored(path.join(this.rootPath, relativePath), relativePath);
273
+ return parser.isIgnored(path.join(resolvedRoot, relativePath), relativePath);
185
274
  }
186
275
 
187
276
  /**
@@ -194,6 +283,7 @@ export class FileWatcher extends EventEmitter {
194
283
  isWatching: this.isWatching,
195
284
  activeWatchers: this.watchers.length,
196
285
  pendingDebounce: this.debounceTimers.size,
286
+ repos: this.repos.map((r) => ({ root: r.root, alias: r.alias })),
197
287
  };
198
288
  }
199
289