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
package/bin/cm-pack.js CHANGED
@@ -25,6 +25,8 @@ and .contextinclude decide which files are included; binary files and files over
25
25
  are skipped. Secrets (API keys, tokens, private keys) are masked.
26
26
 
27
27
  Selection:
28
+ --repo DIR Pack multiple repository roots into one document under a
29
+ shared token budget, prefixed by repo name (repeatable)
28
30
  --include GLOB Only files matching GLOB (repeatable or comma-separated)
29
31
  --exclude GLOB Leave out files matching GLOB (repeatable or comma-separated)
30
32
  --stdin Read the file list from stdin, one path per line, instead of
@@ -62,6 +64,7 @@ Output:
62
64
 
63
65
  Examples:
64
66
  ctxman pack -o context.xml
67
+ ctxman pack --repo ../api --repo ../web --budget 80k -o workspace.xml
65
68
  ctxman pack src --include "**/*.ts" --exclude "**/*.test.ts" --style markdown
66
69
  ctxman pack --compress --budget 100k --instructions task.md --copy
67
70
  git ls-files -z -- '*.py' | ctxman pack --stdin -0 --line-numbers
@@ -93,6 +96,7 @@ function parseSize(text, flag) {
93
96
  }
94
97
 
95
98
  const VALUE_OPTIONS = {
99
+ '--repo': 'repos',
96
100
  '--include': 'include',
97
101
  '--exclude': 'exclude',
98
102
  '--max-file-size': 'maxFileSize',
@@ -131,7 +135,7 @@ const FLAG_OPTIONS = {
131
135
  * @returns {object}
132
136
  */
133
137
  function parseArgs(argv) {
134
- const options = { include: [], exclude: [], tree: true, redact: true, paths: [] };
138
+ const options = { repos: [], include: [], exclude: [], tree: true, redact: true, paths: [] };
135
139
  for (let i = 0; i < argv.length; i++) {
136
140
  const arg = argv[i];
137
141
  if (arg === '--') {
@@ -146,7 +150,9 @@ function parseArgs(argv) {
146
150
  else if (i + 1 < argv.length) value = argv[++i];
147
151
  else throw new Error(`${name} needs a value`);
148
152
  const key = VALUE_OPTIONS[name];
149
- if (key === 'include' || key === 'exclude') {
153
+ if (key === 'repos') {
154
+ options.repos.push(value);
155
+ } else if (key === 'include' || key === 'exclude') {
150
156
  options[key].push(...value.split(',').filter((glob) => glob.trim()));
151
157
  } else {
152
158
  options[key] = value;
@@ -164,6 +170,9 @@ function parseArgs(argv) {
164
170
  if (options.paths.length > 1) {
165
171
  throw new Error(`Expected one project path, got ${options.paths.length}`);
166
172
  }
173
+ if (options.repos.length > 0 && options.stdin) {
174
+ throw new Error('--repo cannot be combined with --stdin');
175
+ }
167
176
  if (options.style !== undefined && !STYLES.includes(options.style)) {
168
177
  throw new Error(`--style must be one of ${STYLES.join(', ')}`);
169
178
  }
@@ -220,8 +229,10 @@ function display(file) {
220
229
  function printSummary(result, extras) {
221
230
  const lines = [];
222
231
  const style = result.style;
232
+ const repoLabel =
233
+ result.repos?.length > 1 ? ` across ${formatNumber(result.repos.length)} repos` : '';
223
234
  lines.push(
224
- `ctxman pack: ${formatNumber(result.files.length)} file(s), ` +
235
+ `ctxman pack: ${formatNumber(result.files.length)} file(s)${repoLabel}, ` +
225
236
  `${formatNumber(result.totalTokens)} tokens (${style}, ${result.encoding})`
226
237
  );
227
238
  if (result.budget) {
@@ -313,7 +324,8 @@ async function main() {
313
324
 
314
325
  try {
315
326
  const { packContext } = await import('../lib/pack/index.js');
316
- const root = path.resolve(args.paths[0] ?? '.');
327
+ const { packWorkspace } = await import('../lib/workspace/index.js');
328
+ const { PolicyConfigError, PolicyViolationError } = await import('../lib/policy/index.js');
317
329
  const output = args.output ? path.resolve(args.output) : null;
318
330
 
319
331
  const options = {
@@ -344,7 +356,26 @@ async function main() {
344
356
  options.cwd = process.cwd();
345
357
  }
346
358
 
347
- const result = await packContext(root, options);
359
+ let result;
360
+ try {
361
+ if (args.repos.length > 0) {
362
+ const repoList = [
363
+ ...(args.paths.length > 0 ? [path.resolve(args.paths[0])] : []),
364
+ ...args.repos.map((r) => path.resolve(r)),
365
+ ];
366
+ result = await packWorkspace(repoList, options);
367
+ } else {
368
+ const root = path.resolve(args.paths[0] ?? '.');
369
+ result = await packContext(root, options);
370
+ }
371
+ } catch (error) {
372
+ if (error instanceof PolicyConfigError || error instanceof PolicyViolationError) {
373
+ process.stderr.write(`Error: ${error.message}\n`);
374
+ process.exitCode = error.exitCode;
375
+ return;
376
+ }
377
+ throw error;
378
+ }
348
379
  const extras = [];
349
380
 
350
381
  if (args.maxTokens && result.totalTokens > args.maxTokens) {
package/bin/cm-policy.js CHANGED
@@ -11,12 +11,34 @@ import {
11
11
  loadPolicy,
12
12
  createStarterPolicy,
13
13
  loadIgnoreFileRules,
14
+ checkWorkspacePolicies,
14
15
  POLICY_FILE,
15
16
  POLICY_ENV,
16
17
  POLICY_DOCS_URL,
17
18
  PolicyConfigError,
18
19
  } from '../lib/policy/index.js';
19
20
 
21
+ function collectRepos(args) {
22
+ const repos = [];
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (arg === '--repo') {
26
+ const next = args[++i];
27
+ if (!next || next.startsWith('-')) {
28
+ throw new Error('--repo needs a directory path');
29
+ }
30
+ repos.push(next);
31
+ } else if (arg.startsWith('--repo=')) {
32
+ const value = arg.slice('--repo='.length);
33
+ if (!value) {
34
+ throw new Error('--repo needs a directory path');
35
+ }
36
+ repos.push(value);
37
+ }
38
+ }
39
+ return repos;
40
+ }
41
+
20
42
  function main() {
21
43
  const args = process.argv.slice(2);
22
44
 
@@ -25,13 +47,27 @@ function main() {
25
47
  return;
26
48
  }
27
49
 
50
+ let repos = [];
51
+ try {
52
+ repos = collectRepos(args);
53
+ } catch (error) {
54
+ console.error(`❌ ${error.message}`);
55
+ process.exitCode = 2;
56
+ return;
57
+ }
58
+
28
59
  const command = args[0] && !args[0].startsWith('-') ? args[0] : 'check';
29
60
 
30
61
  switch (command) {
31
62
  case 'check':
32
- check(args.includes('--json'));
63
+ check(args.includes('--json'), repos);
33
64
  break;
34
65
  case 'init':
66
+ if (repos.length > 0) {
67
+ console.error("❌ --repo is only supported with 'ctxman policy check'");
68
+ process.exitCode = 2;
69
+ return;
70
+ }
35
71
  init(args.includes('--force') || args.includes('-f'));
36
72
  break;
37
73
  default:
@@ -41,8 +77,76 @@ function main() {
41
77
  }
42
78
  }
43
79
 
44
- function check(json) {
80
+ function checkWorkspace(root, json, repos) {
81
+ let result;
82
+ try {
83
+ result = checkWorkspacePolicies(root, repos);
84
+ } catch (error) {
85
+ console.error(`❌ ${error.message}`);
86
+ process.exitCode = 2;
87
+ return;
88
+ }
89
+
90
+ if (!result.valid) {
91
+ process.exitCode = 2;
92
+ }
93
+
94
+ if (json) {
95
+ console.log(JSON.stringify(result, null, 2));
96
+ return;
97
+ }
98
+
99
+ const label = (text) => `${text}:`.padEnd(24);
100
+ console.log('🛡️ ctxman workspace export policy');
101
+ console.log(`Repositories (${result.repos.length}):`);
102
+ for (const repo of result.repos) {
103
+ if (!repo.valid) {
104
+ console.log(` ❌ [${repo.alias}] ${repo.root}`);
105
+ console.error(`❌ [${repo.alias}] ${repo.message}`);
106
+ continue;
107
+ }
108
+ const state = repo.active
109
+ ? `active (${repo.sources.map((s) => s.scope).join(', ')})`
110
+ : 'no policy (unrestricted)';
111
+ console.log(` ✅ [${repo.alias}] ${repo.root} — ${state}`);
112
+ }
113
+ console.log();
114
+ console.log(
115
+ label('Require redaction') + (result.requireRedaction ? 'yes (--no-redact is refused)' : 'no')
116
+ );
117
+ console.log(
118
+ label('Max export tokens') +
119
+ (result.maxExportTokens === null
120
+ ? 'no limit'
121
+ : result.maxExportTokens.toLocaleString('en-US'))
122
+ );
123
+ console.log(
124
+ label('Allowed target models') +
125
+ (!result.allowedTargetModels
126
+ ? 'any'
127
+ : result.allowedTargetModels
128
+ .map((rule) => `${rule.patterns.join(', ')} [${rule.repo}:${rule.scope}]`)
129
+ .join(' AND '))
130
+ );
131
+ console.log(
132
+ label('AI-tool ignore files') +
133
+ (result.ignoreFiles.length === 0 ? 'none' : result.ignoreFiles.join(', '))
134
+ );
135
+ console.log();
136
+ if (result.valid) {
137
+ console.log('✅ All workspace policies are valid.');
138
+ } else {
139
+ console.error('❌ One or more workspace policy files are invalid.');
140
+ }
141
+ }
142
+
143
+ function check(json, repos = []) {
45
144
  const root = process.cwd();
145
+ if (repos.length > 0) {
146
+ checkWorkspace(root, json, repos);
147
+ return;
148
+ }
149
+
46
150
  let policy;
47
151
  try {
48
152
  policy = loadPolicy(root);
@@ -156,6 +260,7 @@ function printHelp() {
156
260
  console.log(' (default)');
157
261
  console.log(` init Write a starter ${POLICY_FILE}\n`);
158
262
  console.log('Options:');
263
+ console.log(' --repo DIR check: include another repository (repeatable)');
159
264
  console.log(' --json check: print the effective policy as JSON');
160
265
  console.log(' -f, --force init: overwrite an existing policy file');
161
266
  console.log(' -h, --help Show this help\n');
package/bin/cm-pr.js CHANGED
@@ -53,7 +53,7 @@ const FLAG_OPTIONS = {
53
53
  * @returns {object}
54
54
  */
55
55
  function parseArgs(argv) {
56
- const parsed = { paths: [] };
56
+ const parsed = { paths: [], repos: [] };
57
57
  for (let i = 0; i < argv.length; i++) {
58
58
  const arg = argv[i];
59
59
  if (arg === '--') {
@@ -62,7 +62,13 @@ function parseArgs(argv) {
62
62
  }
63
63
  const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
64
64
  const name = eq > 0 ? arg.slice(0, eq) : arg;
65
- if (VALUE_OPTIONS[name]) {
65
+ if (name === '--repo') {
66
+ let value;
67
+ if (eq > 0) value = arg.slice(eq + 1);
68
+ else if (i + 1 < argv.length) value = argv[++i];
69
+ else throw new Error('--repo needs a value');
70
+ parsed.repos.push(value);
71
+ } else if (VALUE_OPTIONS[name]) {
66
72
  let value;
67
73
  if (eq > 0) value = arg.slice(eq + 1);
68
74
  else if (i + 1 < argv.length) value = argv[++i];
@@ -109,6 +115,11 @@ function printSummary(result, extras) {
109
115
  const what = result.mode === 'staged' ? 'staged changes' : 'working tree';
110
116
  lines.push(`ctxman pr: ${what} against ${result.base.ref} (${result.mergeBase.short})`);
111
117
  }
118
+ if (Array.isArray(result.repos) && result.repos.length > 0) {
119
+ lines.push(
120
+ ` Repositories (${result.repos.length}): ${result.repos.map((r) => r.alias).join(', ')}`
121
+ );
122
+ }
112
123
  lines.push(
113
124
  ` ${stats.commits} commit(s), ${stats.files} file(s) (+${stats.additions} -${stats.deletions}), ` +
114
125
  `${stats.changedMethods} changed method(s), ${stats.deletedMethods} deleted, ` +
@@ -150,6 +161,7 @@ Arguments:
150
161
  directory); a subdirectory limits the change to that directory
151
162
 
152
163
  What to compare:
164
+ --repo DIR Additional repository root for multi-repo PR review (repeatable)
153
165
  --base REF Base branch/commit (default: origin/HEAD, else main, else master)
154
166
  --head REF Head branch/commit (default: HEAD)
155
167
  --staged Staged changes (index) against HEAD, or against --base
@@ -181,6 +193,7 @@ diff, methods and contents included; excluded files are still listed.
181
193
  Examples:
182
194
  ctxman pr # current branch against the default branch
183
195
  ctxman pr --base main --head feature/login -o review.md
196
+ ctxman pr --repo ../api --repo ../web --base main --budget 16k
184
197
  ctxman pr --working --ask "Anything risky in these changes?"
185
198
  ctxman pr --budget 16k --style xml --with-tests --copy
186
199
  `);
@@ -201,6 +214,7 @@ async function main() {
201
214
  }
202
215
 
203
216
  try {
217
+ const cwd = process.cwd();
204
218
  const options = {
205
219
  base: args.base,
206
220
  head: args.head,
@@ -212,6 +226,7 @@ async function main() {
212
226
  redact: args.redact,
213
227
  style: args.style,
214
228
  ask: args.ask,
229
+ repos: args.repos.length > 0 ? args.repos.map((r) => path.resolve(cwd, r)) : undefined,
215
230
  };
216
231
  if (args.unified !== undefined) options.unified = parseCount(args.unified, '--unified');
217
232
  if (args.budget !== undefined) options.budget = parseTokenCount(args.budget);
@@ -220,7 +235,7 @@ async function main() {
220
235
  }
221
236
 
222
237
  const { buildPrContext } = await import('../lib/pr/pr-context.js');
223
- const result = await buildPrContext(args.paths[0] || process.cwd(), options);
238
+ const result = await buildPrContext(args.paths[0] || cwd, options);
224
239
 
225
240
  const extras = [];
226
241
  let toStdout = !args.output && !args.copy;
package/bin/cm-search.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * ctxman search: ranked (BM25) search over the methods and code chunks of a project
4
+ * ctxman search: ranked (BM25, semantic, or RRF hybrid) search over methods and code chunks
5
5
  *
6
- * ctxman search "<query>" [path] [--limit N] [--max-tokens N] [--json] [--full]
6
+ * ctxman search "<query>" [path] [--repo DIR] [--hybrid | --mode bm25|semantic|hybrid]
7
+ * [--limit N] [--max-tokens N] [--json] [--full]
7
8
  * [--no-redact] [-o FILE] [--copy]
8
9
  */
9
10
 
@@ -11,6 +12,8 @@ import fs from 'fs';
11
12
  import path from 'path';
12
13
  import { parseArgs } from 'util';
13
14
  import { searchProject, DEFAULT_LIMIT, DEFAULT_MAX_TOKENS } from '../lib/search/bm25.js';
15
+ import { hybridSearch, SEARCH_MODES } from '../lib/search/hybrid.js';
16
+ import { searchWorkspace } from '../lib/workspace/index.js';
14
17
  import { getLogger } from '../lib/utils/logger.js';
15
18
  import ClipboardUtils from '../lib/utils/clipboard-utils.js';
16
19
  import { exitOnBrokenPipe } from '../lib/utils/cli-output.js';
@@ -23,6 +26,9 @@ const PREVIEW_LINES = 8;
23
26
  const OPTIONS = {
24
27
  limit: { type: 'string', short: 'n' },
25
28
  'max-tokens': { type: 'string' },
29
+ repo: { type: 'string', multiple: true },
30
+ hybrid: { type: 'boolean' },
31
+ mode: { type: 'string' },
26
32
  json: { type: 'boolean' },
27
33
  full: { type: 'boolean' },
28
34
  'no-redact': { type: 'boolean' },
@@ -36,19 +42,22 @@ const write = (text) => process.stdout.write(text);
36
42
  console.log = console.info = console.debug = console.error;
37
43
 
38
44
  function printHelp() {
39
- write(`ctxman search - Ranked code search (BM25 over methods and code chunks)
45
+ write(`ctxman search - Ranked code search (BM25, semantic, or Reciprocal Rank Fusion hybrid)
40
46
 
41
47
  Usage:
42
48
  ctxman search "<query>" [path] [options]
43
49
 
44
- The query is a few words or identifiers. Identifiers are split into words (getUserName,
45
- get_user_name and get-user-name all mean "get user name"), and a chunk is a method or
46
- function, or a window of up to 40 lines of the code outside them. .gitignore,
47
- .contextignore and .contextinclude apply.
50
+ The query is a few words, identifiers, or a natural-language phrase. Identifiers are split
51
+ into words (getUserName, get_user_name and get-user-name all mean "get user name"), and a
52
+ chunk is a method or function, or a window of up to 40 lines of the code outside them.
53
+ .gitignore, .contextignore, .contextinclude, and .ctxman/policy.json apply.
48
54
 
49
55
  Options:
50
56
  -n, --limit <n> Number of results (default: ${DEFAULT_LIMIT})
51
57
  --max-tokens <n> Token budget for the returned code (default: ${DEFAULT_MAX_TOKENS})
58
+ --repo <dir> Search across multiple repository roots under one token budget (repeatable)
59
+ --hybrid Fuse BM25 lexical search with local vector embeddings via RRF
60
+ --mode <mode> bm25 (default), hybrid, or semantic
52
61
  --full Print whole chunks instead of the first ${PREVIEW_LINES} lines
53
62
  --json Print the results as JSON
54
63
  --no-redact Do not redact secrets in the returned code
@@ -58,6 +67,8 @@ Options:
58
67
 
59
68
  Examples:
60
69
  ctxman search "parse config file"
70
+ ctxman search "how authentication sessions expire" --hybrid
71
+ ctxman search "invoice total" --repo ../api --repo ../billing
61
72
  ctxman search getUserName ./services/api --limit 5
62
73
  ctxman search "retry backoff" --json | jq '.results[].file'
63
74
  `);
@@ -73,14 +84,29 @@ function positiveInteger(value, name) {
73
84
  return number;
74
85
  }
75
86
 
87
+ function resolveMode(values) {
88
+ if (values.hybrid && values.mode !== undefined && values.mode !== 'hybrid') {
89
+ throw new UsageError(`Cannot combine --hybrid with --mode ${values.mode}`);
90
+ }
91
+ const mode = values.hybrid ? 'hybrid' : (values.mode ?? 'bm25');
92
+ if (!SEARCH_MODES.includes(mode)) {
93
+ throw new UsageError(`--mode must be one of ${SEARCH_MODES.join(', ')}, got "${mode}"`);
94
+ }
95
+ return mode;
96
+ }
97
+
76
98
  /**
77
99
  * Human-readable listing
78
- * @param {ReturnType<typeof searchProject>} result
100
+ * @param {object} result
79
101
  * @param {boolean} full - Whole chunks instead of a preview
80
102
  * @returns {string}
81
103
  */
82
104
  function formatResults(result, full) {
83
- const header = `Ranked search for "${result.query}" in ${result.root} (${result.files} files, ${result.chunks} chunks)\n`;
105
+ const modeLabel =
106
+ result.effectiveMode && result.effectiveMode !== 'bm25'
107
+ ? `${result.effectiveMode === 'hybrid' ? 'Hybrid (BM25 + semantic RRF)' : 'Semantic'} search`
108
+ : 'Ranked search';
109
+ const header = `${modeLabel} for "${result.query}" in ${result.root} (${result.files} files, ${result.chunks} chunks)\n`;
84
110
  if (result.results.length === 0) return `${header}\nNo matches.\n`;
85
111
 
86
112
  const width = String(result.results.length).length;
@@ -88,8 +114,17 @@ function formatResults(result, full) {
88
114
  const label = hit.name
89
115
  ? ` ${hit.parent ? `${hit.parent}.` : ''}${hit.name} (${hit.kind})`
90
116
  : '';
117
+ const ranks =
118
+ hit.bm25Rank !== undefined || hit.semanticRank !== undefined
119
+ ? ` [${[
120
+ hit.bm25Rank ? `bm25 #${hit.bm25Rank}` : null,
121
+ hit.semanticRank ? `sem #${hit.semanticRank}` : null,
122
+ ]
123
+ .filter(Boolean)
124
+ .join(', ')}]`
125
+ : '';
91
126
  const lines = [
92
- `${String(i + 1).padStart(width)}. ${hit.file}:${hit.startLine}-${hit.endLine}${label} score ${hit.score}`,
127
+ `${String(i + 1).padStart(width)}. ${hit.file}:${hit.startLine}-${hit.endLine}${label} score ${hit.score}${ranks}`,
93
128
  ];
94
129
  if (hit.text === null) {
95
130
  lines.push(' (code left out: token budget reached)');
@@ -110,7 +145,7 @@ function formatResults(result, full) {
110
145
  return `${header}\n${blocks.join('\n\n')}\n`;
111
146
  }
112
147
 
113
- function main() {
148
+ async function main() {
114
149
  let values;
115
150
  let positionals;
116
151
  try {
@@ -128,12 +163,19 @@ function main() {
128
163
  return;
129
164
  }
130
165
 
166
+ const mode = resolveMode(values);
167
+ const extraRepos = values.repo ?? [];
168
+ if (extraRepos.length > 0 && mode !== 'bm25') {
169
+ throw new UsageError('Multi-repository --repo search currently uses BM25 lexical mode');
170
+ }
171
+
131
172
  // A last positional that names a directory is the project; the rest is the query
132
- let root = process.cwd();
173
+ let explicitRoot = null;
133
174
  const last = positionals.at(-1);
134
175
  if (positionals.length > 1 && fs.statSync(last, { throwIfNoEntry: false })?.isDirectory()) {
135
- root = path.resolve(positionals.pop());
176
+ explicitRoot = path.resolve(positionals.pop());
136
177
  }
178
+ const root = explicitRoot ?? process.cwd();
137
179
  const query = positionals.join(' ').trim();
138
180
  if (!query) throw new UsageError('Missing query: ctxman search "<query>" [path]');
139
181
 
@@ -146,11 +188,34 @@ function main() {
146
188
 
147
189
  // Scan and analyzer progress lines are noise here; LOG_LEVEL still overrides
148
190
  getLogger().level = process.env.LOG_LEVEL || 'warn';
149
- const result = searchProject(root, query, {
150
- limit,
151
- maxTokens,
152
- redact: values['no-redact'] !== true,
153
- });
191
+ const redact = values['no-redact'] !== true;
192
+
193
+ let result;
194
+ if (extraRepos.length > 0) {
195
+ const repoList = [
196
+ ...(explicitRoot ? [explicitRoot] : []),
197
+ ...extraRepos.map((r) => path.resolve(r)),
198
+ ];
199
+ result = searchWorkspace(repoList, query, { limit, maxTokens, redact });
200
+ } else if (mode === 'bm25') {
201
+ result = searchProject(root, query, { limit, maxTokens, redact });
202
+ } else {
203
+ result = await hybridSearch(root, query, {
204
+ mode,
205
+ limit,
206
+ maxTokens,
207
+ redact,
208
+ onProgress: (event) => {
209
+ if (event === 'indexing') {
210
+ console.error('Building local semantic embedding index (.ctxman/self-knowledge)...');
211
+ }
212
+ },
213
+ });
214
+ }
215
+
216
+ if (Array.isArray(result.warnings)) {
217
+ for (const warning of result.warnings) console.error(`Warning: ${warning}`);
218
+ }
154
219
 
155
220
  const output = values.json
156
221
  ? `${JSON.stringify(result, null, 2)}\n`
@@ -166,7 +231,7 @@ function main() {
166
231
  }
167
232
 
168
233
  try {
169
- main();
234
+ await main();
170
235
  } catch (error) {
171
236
  console.error(`Error: ${error.message}`);
172
237
  if (error instanceof UsageError) {