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,6 +1,6 @@
1
1
  {
2
2
  "version": "4.0.0",
3
- "lastUpdated": "2026-10-09",
3
+ "lastUpdated": "2026-10-30",
4
4
  "description": "Context windows, output limits and list prices (USD per 1M tokens, standard tier) of current models. Check prices with the provider before budgeting.",
5
5
  "sources": {
6
6
  "Anthropic": [
@@ -390,7 +390,7 @@
390
390
  {
391
391
  "above": 272000,
392
392
  "input": 20,
393
- "output": 75.0
393
+ "output": 75
394
394
  }
395
395
  ]
396
396
  },
@@ -421,7 +421,7 @@
421
421
  {
422
422
  "above": 272000,
423
423
  "input": 4,
424
- "output": 15.0
424
+ "output": 15
425
425
  }
426
426
  ]
427
427
  },
@@ -485,7 +485,7 @@
485
485
  {
486
486
  "above": 272000,
487
487
  "input": 4,
488
- "output": 18.0
488
+ "output": 18
489
489
  }
490
490
  ]
491
491
  },
@@ -515,7 +515,7 @@
515
515
  {
516
516
  "above": 272000,
517
517
  "input": 10,
518
- "output": 45.0
518
+ "output": 45
519
519
  }
520
520
  ]
521
521
  },
@@ -606,7 +606,7 @@
606
606
  "supportedLanguages": "all",
607
607
  "strengths": ["json-mode", "function-calling"],
608
608
  "tier": "frontier",
609
- "status": "deprecated",
609
+ "status": "retired",
610
610
  "pricing": {
611
611
  "input": 10,
612
612
  "output": 30
package/README.md CHANGED
@@ -81,14 +81,18 @@ If you find this tool helpful, consider buying me a coffee! Your support helps m
81
81
 
82
82
  ### 🧰 Context Commands
83
83
 
84
- - 📦 **`ctxman pack`** - File contents in one LLM-ready document (Claude XML, Markdown, JSON) with signatures-only compression and a hard token budget
85
- - 🗺️ **`ctxman map`** - Ranked repository map (import graph + git history) within a token budget
86
- - 🔍 **`ctxman pr`** - Review context for a branch: log, changed methods, diff, related tests
87
- - 🧭 **`ctxman why`** - Why code looks as it does: commits, linked issues and PRs, owners, co-changed files, ADRs and tests from local git history
88
- - 🔎 **`ctxman search`** - Ranked (BM25) search over functions and code chunks
84
+ - 📦 **`ctxman pack`** - File contents in one LLM-ready document (Claude XML, Markdown, JSON) with signatures-only compression, a hard token budget, and multi-repo `--repo` packing
85
+ - 🎯 **`ctxman focus`** - Pack a file or `file:symbol` together with its `N`-hop import and AST symbol call neighborhood within a token budget (`--repo` for multi-repo; `docs/commands/focus.md`)
86
+ - 🧭 **`ctxman task`** - Task-driven auto context bundle: hybrid search seeds, 1-hop AST call & import code, 2-hop compressed signatures, and repo map (`--repo` for multi-repo; `docs/commands/task.md`)
87
+ - 🩺 **`ctxman diagnose`** - Stack-trace & failure root-cause bundle: implicated frames, enclosing methods, 1-hop AST call & import neighbors, and git history (`--repo` for multi-repo; `docs/commands/diagnose.md`)
88
+ - 👥 **`ctxman who`** - Rank domain experts via surviving `git blame -w` share, recency-decayed commits, and `CODEOWNERS` (`--repo` for multi-repo; `docs/commands/who.md`)
89
+ - 🗺️ **`ctxman map`** - Ranked repository map (import graph + git history) within a token budget (`--repo` for multi-repo)
90
+ - 🔍 **`ctxman pr`** - Review context for a branch: log, changed methods, diff, related tests (`--repo` for multi-repo; `docs/commands/pr.md`)
91
+ - 🧭 **`ctxman why`** - Why code looks as it does: commits, linked issues and PRs, optional GitHub/GitLab discussions (`--discussions`), owners, co-changed files, ADRs and tests (`--repo` for multi-repo; `docs/commands/why.md`)
92
+ - 🔎 **`ctxman search`** - Ranked (BM25) and hybrid BM25 + semantic vector RRF (`--hybrid`) search over functions and code chunks across one or more `--repo` roots
89
93
  - 🔢 **`ctxman count`** - Token count for one model, exact for Claude through Anthropic's API
90
94
  - 🔌 **`ctxman mcp`** - MCP server with one-command setup for Claude Desktop, Claude Code, Cursor, VS Code and Windsurf
91
- - 🛡️ **`ctxman policy` / `ctxman audit`** - Export policy (deny rules, required redaction, token limits) and an audit log
95
+ - 🛡️ **`ctxman policy` / `ctxman audit`** - Export policy (deny rules, required redaction, token limits) and an audit log (`--repo` for multi-repo workspace checks and merged audit trails)
92
96
  - 🤖 **GitHub Action and Docker image** - Token budgets and model fit as a CI check
93
97
 
94
98
  ### 🎨 User Interface
@@ -184,7 +188,7 @@ ctxman --cli --target-model claude-sonnet-5-5
184
188
  # ✅ PERFECT FIT! Your entire codebase fits in one context.
185
189
  ```
186
190
 
187
- Some of the supported models (run `ctxman --list-llms` for the full list, checked 2026-10-09):
191
+ Some of the supported models (run `ctxman --list-llms` for the full list, checked 2026-10-30):
188
192
 
189
193
  | Vendor | Model ID | Context | Output | $ in/out per 1M tokens |
190
194
  | --------- | ------------------------ | ------- | ------ | ---------------------- |
@@ -275,6 +279,7 @@ tests, with your question last.
275
279
  ctxman pr # HEAD against origin/HEAD, main or master
276
280
  ctxman pr --base release/2.x --budget 30k # drops the least important parts to fit
277
281
  ctxman pr --staged --ask "Is this safe to merge?" --copy
282
+ ctxman pr --base main --repo ../billing-api --repo ../checkout-web --budget 24k
278
283
  ctxman pr --style xml -o review.xml
279
284
  ```
280
285
 
@@ -289,6 +294,7 @@ repository record about a file, a function or a line range.
289
294
  ctxman why src/cart.js:total # function / method / class (Class.method)
290
295
  ctxman why src/cart.js#L40-L72 --patches # line range, with each commit's diff
291
296
  ctxman why lib/server.js --since "1 year ago" # whole file, following renames
297
+ ctxman why billing-api/src/invoice.js:createInvoice --repo ../billing-api --repo ../checkout-web
292
298
  ctxman why src/cart.js:total --ask "Why round here?" --budget 8k --copy
293
299
  ```
294
300
 
@@ -322,7 +328,7 @@ ctxman mcp # run the server (stdio)
322
328
  The installer merges into the client's configuration and keeps a `.bak` of the previous file.
323
329
  `--root` confines every tool to one directory. Tools: `analyze_codebase`, `generate_context`,
324
330
  `git_diff`, `search_code`, `list_methods`, `repo_map`, `pr_context`, `get_symbol`,
325
- `count_tokens`, `explain_history`, `pack_context`, `check_policy`, `audit_log`, `ask_codebase`.
331
+ `count_tokens`, `explain_history`, `pack_context`, `focus_context`, `check_policy`, `audit_log`, `ask_codebase`, `task_context`, `diagnose_failure`, `find_experts`, `query_knowledge_graph`.
326
332
  Details:
327
333
  [docs/commands/mcp.md](docs/commands/mcp.md).
328
334
 
@@ -333,8 +339,8 @@ A policy in the repository (`.ctxman/policy.json`), or one an organisation point
333
339
 
334
340
  ```bash
335
341
  ctxman policy init # starter policy: deny .env, keys and secrets/, require redaction
336
- ctxman policy check # validate and show the effective policy
337
- ctxman audit # what was exported, when, by whom, how many tokens
342
+ ctxman policy check # validate and show the effective policy (--repo DIR for multi-repo)
343
+ ctxman audit # what was exported, when, by whom, how many tokens (--repo DIR for multi-repo)
338
344
  ctxman --cli --respect-ignore-files .cursorignore,.aiexclude # honour other AI tools' ignore files
339
345
  ```
340
346
 
package/bin/cli.js CHANGED
@@ -94,11 +94,31 @@ async function main() {
94
94
  return;
95
95
  }
96
96
 
97
+ if (command === 'who') {
98
+ runSubcommand('./cm-who.js', args.slice(1));
99
+ return;
100
+ }
101
+
97
102
  if (command === 'pack') {
98
103
  runSubcommand('./cm-pack.js', args.slice(1));
99
104
  return;
100
105
  }
101
106
 
107
+ if (command === 'focus') {
108
+ runSubcommand('./cm-focus.js', args.slice(1));
109
+ return;
110
+ }
111
+
112
+ if (command === 'task') {
113
+ runSubcommand('./cm-task.js', args.slice(1));
114
+ return;
115
+ }
116
+
117
+ if (command === 'diagnose') {
118
+ runSubcommand('./cm-diagnose.js', args.slice(1));
119
+ return;
120
+ }
121
+
102
122
  if (args.includes('--help') || args.includes('-h')) {
103
123
  printHelp();
104
124
  return;
@@ -708,14 +728,33 @@ function printHelp() {
708
728
  console.log(' --changed-only Analyze only files with uncommitted changes');
709
729
  console.log(' --changed-since REF Analyze files changed since commit/branch');
710
730
  console.log();
731
+ console.log('Task-Driven Context (v3.2.0):');
732
+ console.log(' task "<goal>" [options] Build task-driven context bundle around a goal');
733
+ console.log(' --repo DIR Additional repository root for workspace (repeatable)');
734
+ console.log(' --budget TOKENS Token budget (default: 32000, supports 32k)');
735
+ console.log(' --model MODEL Optimize for specific target model');
736
+ console.log(' --format FORMAT markdown (default), json, xml');
737
+ console.log();
738
+ console.log('Failure Diagnosis (v3.2.0):');
739
+ console.log(' diagnose [file|text] Analyze stack traces and failure logs');
740
+ console.log(' --repo DIR Additional repository root for workspace (repeatable)');
741
+ console.log(' --stdin Read failure log or stack trace from stdin');
742
+ console.log(' --budget TOKENS Token budget (default: 24000)');
743
+ console.log(' --json Output in JSON format');
744
+ console.log();
711
745
  console.log('Platform Features (v3.0.0):');
712
746
  console.log(' serve [options] Start REST API server');
713
747
  console.log(' --port PORT Server port (default: 3000)');
714
748
  console.log(' --auth-token TOKEN API authentication token');
715
749
  console.log(' --host HOST Address to bind (default: localhost)');
716
750
  console.log(' --cors Send CORS headers (off by default)');
717
- console.log(' watch [options] Watch mode with auto-analysis');
751
+ console.log(' watch [options] Watch mode with auto-analysis & live RAG/graph sync');
752
+ console.log(' --repo DIR Additional repository root to watch (repeatable)');
718
753
  console.log(' --debounce MS Debounce delay (default: 1000ms)');
754
+ console.log(' --bootstrap Seed full SQLite vector & graph store on startup');
755
+ console.log(' --no-sync Disable incremental SQLite vector & graph sync');
756
+ console.log(' --no-sync-rag Disable incremental SQLite vector embedding sync');
757
+ console.log(' --no-sync-graph Disable incremental SQLite knowledge-graph sync');
719
758
  console.log();
720
759
  console.log('General Options:');
721
760
  console.log(' -h, --help Show this help');
@@ -754,22 +793,34 @@ function printHelp() {
754
793
  console.log(' pack [path] [options] File contents in one LLM-ready document (xml, markdown,');
755
794
  console.log(' plain, json); --compress, --budget, --split');
756
795
  console.log(' (ctxman pack --help for options)');
796
+ console.log(
797
+ ' focus FILE [path] Target file plus its imports and importers up to --depth N'
798
+ );
799
+ console.log(' (ctxman focus --help for options)');
757
800
  console.log(' map [path] [options] Ranked repository map within a token budget');
758
801
  console.log(' (ctxman map --help for options)');
759
802
  console.log(' pr [--base REF] [--head REF] [path]');
760
803
  console.log(' Review context for a branch: log, changed methods,');
761
- console.log(' diff, related tests (ctxman pr --help)');
804
+ console.log(' diff, related tests (--repo DIR, ctxman pr --help)');
762
805
  console.log(' why FILE[:SYMBOL|#L10-L40] [options]');
763
806
  console.log(' Why the code looks like this: commits, references,');
764
- console.log(' owners, co-changes, docs and tests (ctxman why --help)');
807
+ console.log(
808
+ ' owners, co-changes, docs and tests (--repo DIR, ctxman why --help)'
809
+ );
810
+ console.log(' who TARGET [options] Find code & domain experts across git blame, history,');
811
+ console.log(' and CODEOWNERS (--repo DIR for multi-repo workspaces)');
765
812
  console.log(' search QUERY [path] Ranked (BM25) search over methods and code chunks');
766
- console.log(' count [path|-] [--model ID] [--api]');
767
- console.log(' Count tokens for a model (exact for Claude with --api)');
813
+ console.log(' count [path|-] [--model ID] [--repo DIR] [--api]');
814
+ console.log(' Count tokens for a model or multi-repo workspace');
768
815
  console.log(' mcp [install|uninstall|config]');
769
816
  console.log(' Start the MCP server or add it to an MCP client');
770
- console.log(' policy [check|init] Show or create the export policy (.ctxman/policy.json)');
771
- console.log(' audit [--since DATE] Show the export audit log written under a policy');
772
- console.log(' ask QUESTION Answer a question from the indexed codebase');
817
+ console.log(
818
+ ' policy [check|init] Show or create the export policy (--repo DIR for workspaces)'
819
+ );
820
+ console.log(' audit [--since DATE] Show the export audit log (--repo DIR for workspaces)');
821
+ console.log(
822
+ ' ask QUESTION [options] Answer a question from the indexed codebase (--repo DIR)'
823
+ );
773
824
  console.log(' update [check|install|rollback|channel NAME|info]');
774
825
  console.log(' Check for and manage ctxman updates');
775
826
  console.log();
@@ -971,8 +1022,39 @@ async function runWatchMode(args) {
971
1022
  ? parseInt(args[args.indexOf('--debounce') + 1], 10) || 1000
972
1023
  : 1000;
973
1024
 
974
- const watcher = new FileWatcher(projectRoot, { debounce });
975
- const analyzer = new IncrementalAnalyzer({ methodLevel: args.includes('-m') });
1025
+ const repos = [];
1026
+ for (let i = 0; i < args.length; i++) {
1027
+ if (args[i] === '--repo' && args[i + 1] && !args[i + 1].startsWith('-')) {
1028
+ repos.push(resolve(projectRoot, args[i + 1]));
1029
+ i++;
1030
+ }
1031
+ }
1032
+
1033
+ const noSync = args.includes('--no-sync');
1034
+ const syncRag = !noSync && !args.includes('--no-sync-rag');
1035
+ const syncGraph = !noSync && !args.includes('--no-sync-graph');
1036
+
1037
+ const watcher = new FileWatcher(projectRoot, { debounce, repos });
1038
+ const analyzer = new IncrementalAnalyzer({
1039
+ projectRoot,
1040
+ repos,
1041
+ methodLevel: args.includes('-m'),
1042
+ syncRag,
1043
+ syncGraph,
1044
+ });
1045
+
1046
+ if (repos.length > 0) {
1047
+ console.log(
1048
+ ` 🔗 Multi-repo workspace: ${watcher.repos.map((r) => `${r.alias} (${r.root})`).join(', ')}`
1049
+ );
1050
+ }
1051
+
1052
+ if (args.includes('--bootstrap')) {
1053
+ const boot = await analyzer.bootstrapSync(projectRoot, { repos });
1054
+ console.log(
1055
+ ` 🔄 Bootstrapped SQLite store: ${boot.ragUpserted} embedded (${boot.ragSkipped} unchanged), ${boot.graphNodes} graph nodes, ${boot.graphEdges} edges`
1056
+ );
1057
+ }
976
1058
 
977
1059
  // Handle file changes
978
1060
  watcher.on('file:changed', async (event) => {
@@ -984,6 +1066,17 @@ async function runWatchMode(args) {
984
1066
  analyzer.on('analysis:complete', (event) => {
985
1067
  console.log(` ✅ Analysis complete: ${event.analysis.tokens} tokens (${event.elapsed}ms)`);
986
1068
 
1069
+ if (event.sync?.rag) {
1070
+ console.log(
1071
+ ` 🧠 Vector store (${event.sync.rag.action}): ${event.sync.rag.totalDocuments} indexed files`
1072
+ );
1073
+ }
1074
+ if (event.sync?.graph) {
1075
+ console.log(
1076
+ ` 🕸️ Knowledge graph (${event.sync.graph.action}): ${event.sync.graph.totalNodes} nodes, ${event.sync.graph.totalEdges} edges`
1077
+ );
1078
+ }
1079
+
987
1080
  const stats = analyzer.getStats();
988
1081
  console.log(
989
1082
  ` 📊 Total: ${stats.totalFiles} files, ${stats.totalTokens.toLocaleString()} tokens`
@@ -993,6 +1086,11 @@ async function runWatchMode(args) {
993
1086
  // Handle file deletion
994
1087
  analyzer.on('file:deleted', (event) => {
995
1088
  console.log(` 🗑️ File deleted: ${event.file} (-${event.oldTokens} tokens)`);
1089
+ if (event.sync?.rag || event.sync?.graph) {
1090
+ console.log(
1091
+ ` 🧹 Synced deletion: ${event.sync.rag?.totalDocuments ?? 0} indexed docs, ${event.sync.graph?.totalNodes ?? 0} graph nodes`
1092
+ );
1093
+ }
996
1094
  });
997
1095
 
998
1096
  // Start watching
@@ -1005,6 +1103,7 @@ async function runWatchMode(args) {
1005
1103
  process.on('SIGINT', () => {
1006
1104
  console.log('\n\n🛑 Stopping watch mode...');
1007
1105
  watcher.stop();
1106
+ analyzer.close();
1008
1107
  process.exit(0);
1009
1108
  });
1010
1109
  }
package/bin/cm-ask.js CHANGED
@@ -1,18 +1,70 @@
1
1
  #!/usr/bin/env node
2
- import { askCodebase } from '../lib/rag/ask.js';
2
+ import { parseArgs } from 'node:util';
3
+ import path from 'node:path';
4
+ import { askCodebase, DEFAULT_ASK_LIMIT } from '../lib/rag/ask.js';
5
+
6
+ const HELP = `Usage: ctxman ask "<question>" [options]
7
+
8
+ Semantic search over the codebase (single-repo or multi-repo workspace) using the
9
+ local SQLite embedding index (.ctxman/self-knowledge/index.sqlite).
10
+
11
+ Options:
12
+ --root <dir> Primary project root (default: cwd)
13
+ --repo <dir> Additional repository root for multi-repo workspace (repeatable)
14
+ -n, --limit <n> Maximum snippets to return (default: ${DEFAULT_ASK_LIMIT})
15
+ --json Output result as JSON
16
+ -h, --help Show this help
17
+ `;
18
+
19
+ const OPTIONS = {
20
+ root: { type: 'string' },
21
+ repo: { type: 'string', multiple: true },
22
+ limit: { type: 'string', short: 'n' },
23
+ json: { type: 'boolean' },
24
+ help: { type: 'boolean', short: 'h' },
25
+ };
3
26
 
4
27
  async function main() {
5
- const args = process.argv.slice(2);
6
- const query = args.join(' ');
28
+ const rawArgs = process.argv.slice(2);
29
+ let values = {};
30
+ let positionals = rawArgs;
31
+
32
+ try {
33
+ const parsed = parseArgs({ args: rawArgs, options: OPTIONS, allowPositionals: true });
34
+ values = parsed.values;
35
+ positionals = parsed.positionals;
36
+ } catch {
37
+ // Allow unquoted queries that might contain dashes if parseArgs fails
38
+ positionals = rawArgs;
39
+ }
40
+
41
+ if (values.help) {
42
+ process.stdout.write(HELP);
43
+ return;
44
+ }
45
+
46
+ const query = positionals.join(' ').trim();
7
47
 
8
48
  if (!query) {
9
- console.error('Usage: ctxman ask "Your query here"');
49
+ console.error('Usage: ctxman ask "Your query here" [--repo <dir>] [--limit <n>] [--json]');
10
50
  process.exit(1);
11
51
  }
12
52
 
13
- console.log(`🔍 Searching for: "${query}"...`);
14
- const { results, removedByPolicy } = await askCodebase(process.cwd(), query, {
53
+ const rootDir = values.root ? path.resolve(process.cwd(), values.root) : process.cwd();
54
+ const extraRepos = Array.isArray(values.repo)
55
+ ? values.repo.map((r) => path.resolve(process.cwd(), r))
56
+ : undefined;
57
+ const limit = values.limit ? Number.parseInt(values.limit, 10) : undefined;
58
+
59
+ if (!values.json) {
60
+ console.log(`🔍 Searching for: "${query}"...`);
61
+ }
62
+
63
+ const result = await askCodebase(rootDir, query, {
64
+ repos: extraRepos,
65
+ limit,
15
66
  onProgress: (event) => {
67
+ if (values.json) return;
16
68
  if (event === 'indexing') {
17
69
  console.log('📚 Index not found. Auto-indexing current project...');
18
70
  console.log('⏳ Indexing files (this may take a moment)...');
@@ -22,6 +74,12 @@ async function main() {
22
74
  },
23
75
  });
24
76
 
77
+ if (values.json) {
78
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
79
+ return;
80
+ }
81
+
82
+ const { results, removedByPolicy } = result;
25
83
  if (removedByPolicy > 0) {
26
84
  console.log(`🛡️ Removed ${removedByPolicy} results denied by the export policy`);
27
85
  }
package/bin/cm-audit.js CHANGED
@@ -8,6 +8,7 @@ import path from 'path';
8
8
  import {
9
9
  auditLogFiles,
10
10
  readAuditLog,
11
+ readWorkspaceAuditLogs,
11
12
  filterAuditEvents,
12
13
  formatAuditTable,
13
14
  parseSince,
@@ -17,6 +18,27 @@ import {
17
18
 
18
19
  const DEFAULT_TABLE_LIMIT = 50;
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 value = args[++i];
27
+ if (value === undefined || value.startsWith('--')) {
28
+ throw new Error('--repo needs a value');
29
+ }
30
+ repos.push(value);
31
+ } else if (arg.startsWith('--repo=')) {
32
+ const value = arg.slice('--repo='.length);
33
+ if (!value) {
34
+ throw new Error('--repo needs a value');
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
 
@@ -29,6 +51,7 @@ function main() {
29
51
  let since = null;
30
52
  let limit = json ? 0 : DEFAULT_TABLE_LIMIT;
31
53
  let file = null;
54
+ let repos = [];
32
55
 
33
56
  try {
34
57
  const sinceValue = valueOf(args, '--since');
@@ -43,12 +66,21 @@ function main() {
43
66
  }
44
67
 
45
68
  file = valueOf(args, '--file') ?? null;
69
+ repos = collectRepos(args);
70
+ if (file && repos.length > 0) {
71
+ throw new Error('Cannot combine --file and --repo');
72
+ }
46
73
  } catch (error) {
47
74
  console.error(`❌ ${error.message}`);
48
75
  process.exitCode = 2;
49
76
  return;
50
77
  }
51
78
 
79
+ if (repos.length > 0) {
80
+ runWorkspaceAudit({ repos, since, limit, json });
81
+ return;
82
+ }
83
+
52
84
  const files = file ? [path.resolve(file)] : auditFiles();
53
85
  const { events, skipped, missing } = readAuditLog(files);
54
86
  const selected = filterAuditEvents(events, { since, limit });
@@ -82,6 +114,48 @@ function main() {
82
114
  }
83
115
  }
84
116
 
117
+ function runWorkspaceAudit({ repos, since, limit, json }) {
118
+ let result;
119
+ try {
120
+ result = readWorkspaceAuditLogs(process.cwd(), repos, { since, limit });
121
+ } catch (error) {
122
+ console.error(`❌ ${error.message}`);
123
+ process.exitCode = 2;
124
+ return;
125
+ }
126
+
127
+ for (const err of result.policyErrors) {
128
+ console.error(`⚠️ [${err.repo}] ${err.message}`);
129
+ }
130
+
131
+ if (json) {
132
+ console.log(JSON.stringify(result.events, null, 2));
133
+ } else if (result.files.length === 0) {
134
+ console.log(`No audit logs found across ${result.repos.length} repositories.`);
135
+ console.log(
136
+ `Exports are logged while ${POLICY_FILE} has "audit": {"enabled": true} (ctxman policy init).`
137
+ );
138
+ } else if (result.events.length === 0) {
139
+ console.log(
140
+ since ? `No exports recorded since ${since.toISOString()}.` : 'No exports recorded.'
141
+ );
142
+ } else {
143
+ console.log(formatAuditTable(result.events));
144
+ console.log();
145
+ const shown =
146
+ result.events.length < result.total
147
+ ? `Showing the last ${result.events.length} of ${result.total} events (--limit 0 shows all)`
148
+ : `${result.events.length} event${result.events.length === 1 ? '' : 's'}`;
149
+ console.log(`${shown} across ${result.repos.length} repositories (${result.files.join(', ')})`);
150
+ }
151
+
152
+ if (result.skippedLines > 0) {
153
+ console.error(
154
+ `⚠️ Skipped ${result.skippedLines} line${result.skippedLines === 1 ? '' : 's'} that are not JSON events`
155
+ );
156
+ }
157
+ }
158
+
85
159
  // The policy's audit logs; the default location when no policy names one
86
160
  function auditFiles() {
87
161
  const { files, error } = auditLogFiles(process.cwd());
@@ -106,6 +180,8 @@ function printHelp() {
106
180
  console.log('ctxman audit - show the export audit log\n');
107
181
  console.log('Usage: ctxman audit [options]\n');
108
182
  console.log('Options:');
183
+ console.log(' --repo DIR Include another repository in the merged audit log');
184
+ console.log(' (repeatable)');
109
185
  console.log(' --since DATE Only exports at or after DATE: a date (2026-10-01), an ISO');
110
186
  console.log(' time, or an age such as 30m, 12h, 7d, 2w');
111
187
  console.log(` --limit N Show the last N exports (default: ${DEFAULT_TABLE_LIMIT};`);
package/bin/cm-count.js CHANGED
@@ -20,11 +20,12 @@ getLogger().level = 'warn';
20
20
 
21
21
  const HELP = `Usage: ctxman count [path | -] [options]
22
22
 
23
- Count the tokens of a project (or of text piped to stdin with "-") for a target model,
24
- and check them against the model's context window.
23
+ Count the tokens of a project, multi-repository workspace, or text piped to stdin with "-"
24
+ for a target model, and check them against the model's context window.
25
25
 
26
26
  Options:
27
27
  --model, -m ID Target model (default: ${LLMDetector.getDefaultModelId()}; see ctxman --list-llms)
28
+ --repo DIR Additional repository root to count in a workspace (repeatable)
28
29
  --api Claude models: count exactly with Anthropic's count_tokens API.
29
30
  Sends the (secret-redacted) text to Anthropic; needs ANTHROPIC_API_KEY
30
31
  and the @anthropic-ai/sdk package. Counting is free and generates nothing.
@@ -39,26 +40,46 @@ their counts are approximate.
39
40
  Examples:
40
41
  ctxman count --model gpt-6.1-sol
41
42
  ctxman count --model claude-opus-5-5 --api
43
+ ctxman count ./api --repo ../shared --repo ../web --model claude-sonnet-5-5
42
44
  ctxman pack | ctxman count - --model claude-sonnet-5-5 --api`;
43
45
 
44
46
  function parseArgs(argv) {
45
- const options = { top: 5 };
47
+ const options = { top: 5, repos: [] };
46
48
  for (let i = 0; i < argv.length; i++) {
47
49
  const arg = argv[i];
48
50
  if (arg === '--model' || arg === '-m') options.model = argv[++i];
49
- else if (arg === '--api') options.api = true;
51
+ else if (arg.startsWith('--model=')) options.model = arg.slice('--model='.length);
52
+ else if (arg === '--repo') {
53
+ const repoDir = argv[++i];
54
+ if (!repoDir || repoDir.startsWith('-')) {
55
+ throw new Error('--repo needs a directory path');
56
+ }
57
+ options.repos.push(repoDir);
58
+ } else if (arg.startsWith('--repo=')) {
59
+ const repoDir = arg.slice('--repo='.length);
60
+ if (!repoDir) throw new Error('--repo needs a directory path');
61
+ options.repos.push(repoDir);
62
+ } else if (arg === '--api') options.api = true;
50
63
  else if (arg === '--json') options.json = true;
51
64
  else if (arg === '--top') options.top = Number.parseInt(argv[++i], 10);
65
+ else if (arg.startsWith('--top='))
66
+ options.top = Number.parseInt(arg.slice('--top='.length), 10);
52
67
  else if (arg === '--help' || arg === '-h') options.help = true;
53
68
  else if (arg === '-' || !arg.startsWith('-')) options.target = arg;
54
69
  else throw new Error(`Unknown option: ${arg}`);
55
70
  }
56
- if (options.model === undefined && argv.includes('--model')) {
71
+ if (
72
+ (options.model === undefined || options.model === '') &&
73
+ argv.some((a) => a === '--model' || a === '-m' || a === '--model=')
74
+ ) {
57
75
  throw new Error('--model needs a model id');
58
76
  }
59
77
  if (!Number.isInteger(options.top) || options.top < 0) {
60
78
  throw new Error('--top needs a number of files');
61
79
  }
80
+ if (options.target === '-' && options.repos.length > 0) {
81
+ throw new Error('Cannot combine --repo with stdin ("-")');
82
+ }
62
83
  return options;
63
84
  }
64
85
 
@@ -89,8 +110,17 @@ function printResult(result, top) {
89
110
  print(` For an exact count: ctxman count --model ${result.model} --api`);
90
111
  }
91
112
 
113
+ if (result.repos?.length > 1) {
114
+ print('\nRepositories:');
115
+ for (const repo of result.repos) {
116
+ print(
117
+ ` ${n(repo.tokens).padStart(9)} ${repo.alias} (${n(repo.files)} file${repo.files === 1 ? '' : 's'}, ${repo.percentOfWindow.toFixed(1)}%)`
118
+ );
119
+ }
120
+ }
121
+
92
122
  const largest = result.largestFiles.slice(0, top);
93
- if (largest.length > 1) {
123
+ if (largest.length > 1 || (result.repos?.length > 1 && largest.length > 0)) {
94
124
  const scale = result.method === 'anthropic-api' && result.ratio ? result.ratio : 1;
95
125
  print(`\nLargest files${scale !== 1 ? ' (≈, scaled to the API count)' : ''}:`);
96
126
  for (const file of largest) {
@@ -119,6 +149,14 @@ async function main() {
119
149
  process.exit(1);
120
150
  }
121
151
 
152
+ const repos = options.repos.map((r) => path.resolve(r));
153
+ for (const repoPath of repos) {
154
+ if (!fs.statSync(repoPath, { throwIfNoEntry: false })?.isDirectory()) {
155
+ console.error(`❌ Not a directory: ${repoPath}`);
156
+ process.exit(1);
157
+ }
158
+ }
159
+
122
160
  const model = options.model || LLMDetector.getDefaultModelId();
123
161
  if (options.api && LLMDetector.getProfile(model).vendor === 'Anthropic') {
124
162
  console.error('Sending the redacted text to the Anthropic API to count tokens...');
@@ -129,6 +167,7 @@ async function main() {
129
167
  model,
130
168
  api: options.api,
131
169
  text,
170
+ repos,
132
171
  onProgress: (done, total) => {
133
172
  if (total > 1) process.stderr.write(`\r request ${done}/${total}`);
134
173
  if (total > 1 && done === total) process.stderr.write('\n');