ctxman 3.1.1 → 3.2.7

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 (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. package/package.json +1 -1
package/bin/cm-task.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ctxman task - task-driven auto context pack
4
+ *
5
+ * stdout carries the context document (unless -o/--output or --copy); summary goes to stderr.
6
+ * Exit codes: 0 done, 1 error, 2 invalid arguments / options.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getLogger } from '../lib/utils/logger.js';
12
+
13
+ function printHelp() {
14
+ process.stdout.write(`ctxman task - build task-driven context bundle around a goal
15
+
16
+ Usage: ctxman task "<goal>" [root] [options]
17
+
18
+ Discovers seed files and symbols via hybrid search, traverses 1-hop full code
19
+ and 2-hop compressed signatures around them (across module imports and the
20
+ cross-file AST symbol call graph), prepends an architectural repo map,
21
+ and packs everything strictly within the token budget.
22
+
23
+ Options:
24
+ --repo DIR Additional repository root to include in workspace (repeatable)
25
+ --budget TOKENS Token budget (e.g. 32000, 32k, 40k; default: 32000)
26
+ --model MODEL Target model profile (e.g. claude-sonnet-4-5)
27
+ --format FORMAT Output format: markdown (default), json, xml
28
+ --json Shorthand for --format json
29
+ --search-mode MODE Search mode: hybrid (default), bm25, semantic
30
+ --calls Include cross-file AST symbol call graph edges (default)
31
+ --no-calls Disable AST symbol call graph edges (module imports only)
32
+ --no-redact Do not mask secrets
33
+ -o, --output FILE Write output to FILE instead of stdout
34
+ --copy Copy document to clipboard
35
+ -q, --quiet Do not print summary to stderr
36
+ -h, --help Show this help
37
+
38
+ Examples:
39
+ ctxman task "Add SQLite vector store"
40
+ ctxman task "Fix OAuth token refresh" --budget 40k --format json
41
+ ctxman task "Add invoice checkout webhook" --repo ../billing-api --repo ../checkout-web
42
+ ctxman task "Refactor tokenizer" -o task-context.md
43
+ `);
44
+ }
45
+
46
+ async function main() {
47
+ const argv = process.argv.slice(2);
48
+ if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
49
+ printHelp();
50
+ return;
51
+ }
52
+
53
+ getLogger({ level: process.env.LOG_LEVEL || 'error', logToFile: false });
54
+ console.log = console.info = console.debug = console.error;
55
+
56
+ const { buildTaskContext, parseBudget } = await import('../lib/task/index.js');
57
+
58
+ let goal = null;
59
+ let root = process.cwd();
60
+ const repos = [];
61
+ let budget = 32000;
62
+ let model = null;
63
+ let format = 'markdown';
64
+ let searchMode = 'hybrid';
65
+ let callGraph = true;
66
+ let redact = true;
67
+ let outputFile = null;
68
+ let copy = false;
69
+ let quiet = false;
70
+
71
+ try {
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const arg = argv[i];
74
+
75
+ if (arg === '--help' || arg === '-h') {
76
+ printHelp();
77
+ return;
78
+ } else if (arg === '--root' || arg === '-r') {
79
+ if (i + 1 >= argv.length) throw new Error('--root needs a directory path');
80
+ root = path.resolve(argv[++i]);
81
+ } else if (arg.startsWith('--root=')) {
82
+ root = path.resolve(arg.slice('--root='.length));
83
+ } else if (arg === '--repo') {
84
+ if (i + 1 >= argv.length) throw new Error('--repo needs a directory path');
85
+ repos.push(path.resolve(argv[++i]));
86
+ } else if (arg.startsWith('--repo=')) {
87
+ const val = arg.slice('--repo='.length);
88
+ if (!val) throw new Error('--repo needs a directory path');
89
+ repos.push(path.resolve(val));
90
+ } else if (arg === '--budget') {
91
+ budget = parseBudget(argv[++i]);
92
+ } else if (arg.startsWith('--budget=')) {
93
+ budget = parseBudget(arg.slice('--budget='.length));
94
+ } else if (arg === '--model' || arg === '--target-model') {
95
+ model = argv[++i];
96
+ } else if (arg.startsWith('--model=')) {
97
+ model = arg.slice('--model='.length);
98
+ } else if (arg === '--format') {
99
+ format = argv[++i];
100
+ } else if (arg.startsWith('--format=')) {
101
+ format = arg.slice('--format='.length);
102
+ } else if (arg === '--json') {
103
+ format = 'json';
104
+ } else if (arg === '--search-mode') {
105
+ searchMode = argv[++i];
106
+ } else if (arg.startsWith('--search-mode=')) {
107
+ searchMode = arg.slice('--search-mode='.length);
108
+ } else if (arg === '--calls' || arg === '--call-graph') {
109
+ callGraph = true;
110
+ } else if (arg === '--no-calls' || arg === '--no-call-graph') {
111
+ callGraph = false;
112
+ } else if (arg === '--no-redact') {
113
+ redact = false;
114
+ } else if (arg === '-o' || arg === '--output') {
115
+ outputFile = argv[++i];
116
+ } else if (arg.startsWith('-o=')) {
117
+ outputFile = arg.slice('-o='.length);
118
+ } else if (arg.startsWith('--output=')) {
119
+ outputFile = arg.slice('--output='.length);
120
+ } else if (arg === '--copy') {
121
+ copy = true;
122
+ } else if (arg === '-q' || arg === '--quiet') {
123
+ quiet = true;
124
+ } else if (!arg.startsWith('-')) {
125
+ if (goal === null) {
126
+ goal = arg;
127
+ } else if (fs.existsSync(arg) && fs.statSync(arg).isDirectory()) {
128
+ root = arg;
129
+ } else {
130
+ goal += ` ${arg}`;
131
+ }
132
+ } else {
133
+ process.stderr.write(`Unknown option: ${arg}\n`);
134
+ process.exitCode = 2;
135
+ return;
136
+ }
137
+ }
138
+ } catch (error) {
139
+ process.stderr.write(`Error: ${error.message}\n`);
140
+ process.exitCode = 2;
141
+ return;
142
+ }
143
+
144
+ if (!goal) {
145
+ process.stderr.write('Error: A task goal is required.\n');
146
+ process.exitCode = 2;
147
+ return;
148
+ }
149
+
150
+ try {
151
+ const result = await buildTaskContext(root, goal, {
152
+ repos,
153
+ budget,
154
+ model,
155
+ format,
156
+ searchMode,
157
+ callGraph,
158
+ redact,
159
+ output: outputFile,
160
+ });
161
+
162
+ if (copy) {
163
+ const { default: ClipboardUtils } = await import('../lib/utils/clipboard-utils.js');
164
+ await ClipboardUtils.copy(result.text);
165
+ if (!quiet) {
166
+ process.stderr.write(`Copied ${result.totalTokens} tokens to clipboard.\n`);
167
+ }
168
+ } else if (!outputFile) {
169
+ process.stdout.write(result.text + (result.text.endsWith('\n') ? '' : '\n'));
170
+ }
171
+
172
+ if (!quiet) {
173
+ const repoLabel = result.repos?.length > 1 ? ` across ${result.repos.length} repos` : '';
174
+ process.stderr.write(
175
+ `Task Context: ${result.files.length} file(s)${repoLabel}, ${result.totalTokens}/${result.budget} tokens (${result.format}).\n`
176
+ );
177
+ if (outputFile) {
178
+ process.stderr.write(`Saved to: ${outputFile}\n`);
179
+ }
180
+ }
181
+ } catch (error) {
182
+ process.stderr.write(`Error: ${error.message}\n`);
183
+ process.exitCode = error.exitCode ?? 1;
184
+ }
185
+ }
186
+
187
+ process.stdout.on('error', (error) => {
188
+ if (error.code !== 'EPIPE') throw error;
189
+ process.exit(process.exitCode ?? 0);
190
+ });
191
+
192
+ main();
package/bin/cm-who.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ctxman who - "Who knows about this?" Expert Finder across git blame, recency-decayed commit history,
4
+ * CODEOWNERS stewardship, and natural-language topic search.
5
+ */
6
+
7
+ import { parseArgs } from 'node:util';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { getLogger } from '../lib/utils/logger.js';
11
+
12
+ getLogger({ level: 'error', logToFile: false });
13
+ const writeOut = (text) => process.stdout.write(text);
14
+ console.log = console.info = console.debug = console.error;
15
+
16
+ const { findExperts, WhoError, DEFAULT_WHO_LIMIT, DEFAULT_COMMIT_WINDOW, DEFAULT_HALF_LIFE_DAYS } =
17
+ await import('../lib/who/index.js');
18
+
19
+ const HELP = `Usage: ctxman who <target-or-topic> [options]
20
+
21
+ Find the code and domain experts for a file, symbol, line range, directory, or
22
+ natural-language topic by fusing surviving git blame ownership, recency-decayed
23
+ commit/churn depth, and CODEOWNERS stewardship (single-repo or multi-repo workspace).
24
+
25
+ Targets:
26
+ <file> A single file (e.g. lib/why/index.js or <alias>/src/index.js)
27
+ <file>:<symbol> A function, method, or class (e.g. lib/why/index.js:explainHistory)
28
+ <file>#L10-L40 A line range
29
+ <directory> A directory / subsystem (e.g. lib/rag or <alias>/src)
30
+ "<topic query>" A natural-language concept resolved via BM25 code search
31
+
32
+ Options:
33
+ --root <dir> Primary repository root (default: cwd)
34
+ --repo <dir> Additional repository root for multi-repo workspace (repeatable)
35
+ -n, --limit <n> Maximum experts to return (default: ${DEFAULT_WHO_LIMIT})
36
+ --commits <n> Commit window to examine (default: ${DEFAULT_COMMIT_WINDOW})
37
+ --half-life <days> Recency decay half-life in days (default: ${DEFAULT_HALF_LIFE_DAYS})
38
+ --since <date> Only examine commits after this date
39
+ --style <style> markdown (default), xml, or json
40
+ --json Same as --style json
41
+ -o, --output <file> Write output to a file instead of stdout
42
+ -h, --help Show this help
43
+ `;
44
+
45
+ const OPTIONS = {
46
+ root: { type: 'string' },
47
+ repo: { type: 'string', multiple: true },
48
+ limit: { type: 'string', short: 'n' },
49
+ commits: { type: 'string' },
50
+ 'half-life': { type: 'string' },
51
+ since: { type: 'string' },
52
+ style: { type: 'string' },
53
+ json: { type: 'boolean' },
54
+ output: { type: 'string', short: 'o' },
55
+ help: { type: 'boolean', short: 'h' },
56
+ };
57
+
58
+ function fail(message, exitCode = 1) {
59
+ process.stderr.write(`ctxman who: ${message}\n`);
60
+ process.exitCode = exitCode;
61
+ }
62
+
63
+ async function run() {
64
+ let parsed;
65
+ try {
66
+ parsed = parseArgs({ options: OPTIONS, allowPositionals: true });
67
+ } catch (err) {
68
+ fail(err.message);
69
+ return;
70
+ }
71
+
72
+ const { values, positionals } = parsed;
73
+ if (values.help) {
74
+ writeOut(HELP);
75
+ return;
76
+ }
77
+
78
+ const target = positionals.join(' ').trim();
79
+ if (!target) {
80
+ fail('missing <target-or-topic>. Run `ctxman who --help` for usage.');
81
+ return;
82
+ }
83
+
84
+ const style = values.json ? 'json' : values.style || 'markdown';
85
+ if (!['markdown', 'xml', 'json'].includes(style)) {
86
+ fail(`unsupported --style "${style}" (expected markdown, xml, or json)`);
87
+ return;
88
+ }
89
+
90
+ const rootDir = values.root ? path.resolve(process.cwd(), values.root) : process.cwd();
91
+ const extraRepos = Array.isArray(values.repo)
92
+ ? values.repo.map((r) => path.resolve(process.cwd(), r))
93
+ : undefined;
94
+
95
+ try {
96
+ const result = await findExperts(rootDir, target, {
97
+ repos: extraRepos,
98
+ limit: values.limit ? Number(values.limit) : undefined,
99
+ commitWindow: values.commits ? Number(values.commits) : undefined,
100
+ halfLifeDays: values['half-life'] ? Number(values['half-life']) : undefined,
101
+ since: values.since,
102
+ style,
103
+ });
104
+
105
+ if (values.output) {
106
+ fs.writeFileSync(values.output, result.text, 'utf8');
107
+ } else {
108
+ writeOut(result.text);
109
+ }
110
+ } catch (err) {
111
+ if (err instanceof WhoError) {
112
+ fail(err.message);
113
+ return;
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ await run();
package/bin/cm-why.js CHANGED
@@ -30,22 +30,30 @@ Explain why code looks the way it does: the commits that shaped it (with their
30
30
  messages), the issues, tickets and URLs they reference, blame ownership, files
31
31
  that change together with it, docs / ADRs / changelog entries / comments that
32
32
  mention it, TODO-style notes inside it and the tests that reference it. Built
33
- from the local git history; no network access.
33
+ from the local git history (offline by default; opt in to --discussions for
34
+ GitHub/GitLab issue, PR and MR context).
34
35
 
35
- Targets (paths are relative to the current directory, inside the repository):
36
+ Targets (paths are relative to the current directory, or <alias>/<file> with --repo):
36
37
  <file> The whole file (history follows renames)
37
38
  <file>:<symbol> A function, method or class: total, Cart.total, Cart::total
38
39
  <file>#L10-L40 A line range (also <file>#L10 and <file>:10-40)
39
40
 
40
41
  Options:
42
+ --repo <dir> Additional repository root for multi-repo workspaces (repeatable)
41
43
  -n, --limit <n> Commits to include (default: 20)
42
44
  --since <date> Only commits after this date ("2024-01-01", "6 months ago")
43
45
  --rev <ref> Explain the code as of this revision (default: working tree)
44
46
  --patches Include each commit's diff of the target
47
+ --analyzer-db <path> Query @plansmithai/analyzer SQLite DB for institutional citations
48
+ --analyzer Enable analyzer citations from .plansmith/analyzer.sqlite
49
+ --discussions Fetch referenced GitHub/GitLab issue, PR and MR discussions
50
+ (uses GITHUB_TOKEN/GH_TOKEN or GITLAB_TOKEN when set)
51
+ --max-discussions <n> Maximum remote discussions to fetch (default: 10)
52
+ --max-comments <n> Maximum comments fetched per discussion (default: 5)
45
53
  --coupling-window <n> Commits of the file examined for co-changes (default: 500, 0 = off)
46
54
  --budget <tokens> Keep the output within this many tokens (8000 or 8k); drops
47
- patches, related text, tests, coupling details and older
48
- commits first
55
+ patches, discussions, related text, tests, coupling details
56
+ and older commits first
49
57
  --style <style> markdown (default), xml or json
50
58
  --json Same as --style json
51
59
  --ask <question> Append a question at the end of the document
@@ -56,16 +64,24 @@ Options:
56
64
 
57
65
  Examples:
58
66
  ctxman why src/cart.js:total
67
+ ctxman why backend/src/cart.js:total --repo ../docs-repo --repo ../e2e-tests
68
+ ctxman why src/cart.js:total --discussions
59
69
  ctxman why src/cart.js#L40-L72 --patches --budget 8000
60
70
  ctxman why lib/server.js --since "1 year ago" --style xml -o why.xml
61
71
  ctxman why src/cart.js:total --ask "Why do we round here instead of in applyDiscount?" --copy
62
72
  `;
63
73
 
64
74
  const OPTIONS = {
75
+ repo: { type: 'string', multiple: true },
65
76
  limit: { type: 'string', short: 'n' },
66
77
  since: { type: 'string' },
67
78
  rev: { type: 'string' },
68
79
  patches: { type: 'boolean' },
80
+ 'analyzer-db': { type: 'string' },
81
+ analyzer: { type: 'boolean' },
82
+ discussions: { type: 'boolean' },
83
+ 'max-discussions': { type: 'string' },
84
+ 'max-comments': { type: 'string' },
69
85
  'coupling-window': { type: 'string' },
70
86
  budget: { type: 'string' },
71
87
  style: { type: 'string' },
@@ -108,6 +124,10 @@ function summarize(result) {
108
124
  const parts = [
109
125
  plural(data.history.commits.length, 'commit'),
110
126
  plural(references, 'reference'),
127
+ ...(data.institutionalCitations?.length
128
+ ? [plural(data.institutionalCitations.length, 'institutional citation')]
129
+ : []),
130
+ ...(data.discussions?.length ? [plural(data.discussions.length, 'discussion')] : []),
111
131
  plural(data.ownership?.authors.length ?? 0, 'author'),
112
132
  plural(data.coupling?.files.length ?? 0, 'co-changed file'),
113
133
  plural(data.related.length, 'related snippet'),
@@ -148,15 +168,31 @@ async function main() {
148
168
  }
149
169
 
150
170
  const cwd = process.cwd();
171
+ const extraRepos = values.repo?.length ? values.repo.map((r) => path.resolve(cwd, r)) : undefined;
151
172
  let result;
152
173
  try {
153
- const root = findRepositoryRoot(cwd);
174
+ let root = cwd;
175
+ if (extraRepos) {
176
+ try {
177
+ root = findRepositoryRoot(cwd);
178
+ } catch {
179
+ root = cwd;
180
+ }
181
+ } else {
182
+ root = findRepositoryRoot(cwd);
183
+ }
154
184
  result = await explainHistory(root, positionals[0], {
155
185
  cwd,
186
+ repos: extraRepos,
156
187
  limit: values.limit,
157
188
  since: values.since,
158
189
  rev: values.rev,
159
190
  patches: values.patches,
191
+ discussions: values.discussions,
192
+ analyzerDb: values['analyzer-db'],
193
+ analyzer: values.analyzer,
194
+ maxDiscussions: values['max-discussions'],
195
+ maxComments: values['max-comments'],
160
196
  couplingWindow: values['coupling-window'],
161
197
  budget: parseTokenCount(values.budget),
162
198
  style: values.json ? 'json' : values.style,