ctxman 3.1.0 → 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 (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  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/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  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/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
package/lib/why/render.js CHANGED
@@ -12,6 +12,8 @@ export const STYLES = ['markdown', 'xml', 'json'];
12
12
  /** Sections a budget can shorten */
13
13
  export const DROP_ORDER = [
14
14
  'patches',
15
+ 'discussions',
16
+ 'citations',
15
17
  'related',
16
18
  'tests',
17
19
  'coupling',
@@ -23,13 +25,15 @@ export const DROP_ORDER = [
23
25
  ];
24
26
 
25
27
  /**
26
- * What a budget drops, first to last, as [section, items kept at least]. The first five
27
- * steps follow the documented priority (patches, related text, tests, coupling details,
28
- * older commits, keeping the newest one); the rest only run when the budget is smaller
29
- * than the core of the document
28
+ * What a budget drops, first to last, as [section, items kept at least]. The first six
29
+ * steps follow the documented priority (patches, remote discussions, related text, tests,
30
+ * coupling details, older commits, keeping the newest one); the rest only run when the
31
+ * budget is smaller than the core of the document
30
32
  */
31
33
  const DROP_STEPS = [
32
34
  ['patches', 0],
35
+ ['discussions', 0],
36
+ ['citations', 0],
33
37
  ['related', 0],
34
38
  ['tests', 0],
35
39
  ['coupling', 0],
@@ -43,6 +47,8 @@ const DROP_STEPS = [
43
47
 
44
48
  const OMITTED_LABELS = {
45
49
  patches: 'patches',
50
+ discussions: 'remote discussions',
51
+ citations: 'institutional citations',
46
52
  related: 'related text snippets',
47
53
  tests: 'test files',
48
54
  coupling: 'co-changed files',
@@ -106,6 +112,8 @@ function flattenReferences(references) {
106
112
  export function countItems(data) {
107
113
  return {
108
114
  patches: data.history.commits.filter((commit) => commit.patch).length,
115
+ discussions: (data.discussions ?? []).length,
116
+ citations: (data.institutionalCitations ?? []).length,
109
117
  related: data.related.length,
110
118
  tests: data.tests.length,
111
119
  coupling: data.coupling?.files.length ?? 0,
@@ -162,6 +170,8 @@ export function applyView(data, view) {
162
170
  target,
163
171
  history: { ...data.history, commits },
164
172
  references,
173
+ discussions: (data.discussions ?? []).slice(0, view.discussions),
174
+ institutionalCitations: (data.institutionalCitations ?? []).slice(0, view.citations ?? 0),
165
175
  ownership: data.ownership && {
166
176
  ...data.ownership,
167
177
  authors: data.ownership.authors.slice(0, view.ownership),
@@ -293,8 +303,11 @@ function historyDescription(history) {
293
303
 
294
304
  function renderMarkdown(data, budget) {
295
305
  const out = [`# Why: ${title(data.target)}`, ''];
306
+ const hasDiscussions = (data.discussions?.length ?? 0) > 0 || Boolean(data.omitted?.discussions);
296
307
  out.push(
297
- 'Context for explaining why this code looks the way it does, collected by `ctxman why` from the local git history and the repository (no network).',
308
+ hasDiscussions
309
+ ? 'Context for explaining why this code looks the way it does, collected by `ctxman why` from the local git history, repository, and remote issue/PR discussions.'
310
+ : 'Context for explaining why this code looks the way it does, collected by `ctxman why` from the local git history and the repository (no network).',
298
311
  ''
299
312
  );
300
313
  for (const warning of data.warnings) out.push(`> **Warning:** ${warning}`);
@@ -307,6 +320,8 @@ function renderMarkdown(data, budget) {
307
320
  markdownTarget,
308
321
  markdownHistory,
309
322
  markdownReferences,
323
+ markdownDiscussions,
324
+ markdownCitations,
310
325
  markdownOwnership,
311
326
  markdownCoupling,
312
327
  markdownRelated,
@@ -320,8 +335,11 @@ function renderMarkdown(data, budget) {
320
335
  return out.join('\n');
321
336
  }
322
337
 
323
- function markdownTarget({ target, repository, omitted }) {
338
+ function markdownTarget({ target, repository, repos, omitted }) {
324
339
  const out = ['## Target', '', `- File: ${code(target.path)}`];
340
+ if (Array.isArray(repos) && repos.length > 0) {
341
+ out.push(`- Workspace repositories: ${repos.map((r) => code(r.alias)).join(', ')}`);
342
+ }
325
343
  if (target.symbol) {
326
344
  const line = target.line ? `, declared on line ${target.line}` : '';
327
345
  out.push(`- Symbol: ${code(target.symbol)} (${target.kind}${line})`);
@@ -372,7 +390,7 @@ function markdownHistory({ history, omitted }) {
372
390
  );
373
391
  }
374
392
  if (commit.refs?.length) meta.push(`refs: ${commit.refs.join(', ')}`);
375
- out.push(meta.join(' · '));
393
+ out.push(meta.join(' \u00b7 '));
376
394
  if (commit.body) {
377
395
  out.push('', commit.body.replace(/^/gm, '> ').replace(/^> $/gm, '>'));
378
396
  }
@@ -407,6 +425,68 @@ function markdownReferences({ references, history, omitted }) {
407
425
  return out;
408
426
  }
409
427
 
428
+ function markdownDiscussions({ discussions = [], omitted }) {
429
+ if (!discussions.length) return [];
430
+ const out = ['## Discussions', ''];
431
+ for (const item of discussions) {
432
+ const label = { pr: 'Pull request', mr: 'Merge request' }[item.kind] ?? 'Issue';
433
+ const titleText = item.title ? `: ${item.title}` : '';
434
+ out.push(`### ${label} ${item.id}${titleText}`, '');
435
+ const meta = [item.state, item.author ? `@${item.author}` : null, day(item.createdAt)].filter(
436
+ Boolean
437
+ );
438
+ if (item.labels?.length) meta.push(`labels: ${item.labels.join(', ')}`);
439
+ if (item.commits?.length) meta.push(`commits: ${item.commits.join(', ')}`);
440
+ if (item.url) meta.push(item.url);
441
+ out.push(meta.join(' \u00b7 '));
442
+ if (item.body) {
443
+ out.push('', item.body.replace(/^/gm, '> ').replace(/^> $/gm, '>'));
444
+ }
445
+ if (item.comments?.length) {
446
+ out.push('', 'Comments:');
447
+ for (const comment of item.comments) {
448
+ const header = `@${comment.author}${comment.createdAt ? ` (${day(comment.createdAt)})` : ''}`;
449
+ const singleLine = comment.body.replace(/\n+/g, ' ');
450
+ out.push(`- **${header}:** ${singleLine}`);
451
+ }
452
+ }
453
+ out.push('');
454
+ }
455
+ if (out.at(-1) === '') out.pop();
456
+ if (omitted.discussions) {
457
+ out.push(
458
+ '',
459
+ `_${omitted.discussions} remote discussion(s) not shown to fit the token budget._`
460
+ );
461
+ }
462
+ return out;
463
+ }
464
+
465
+ function markdownCitations({ institutionalCitations, omitted }) {
466
+ if (!institutionalCitations || !institutionalCitations.length) return [];
467
+ const out = ['## Institutional Citations (Analyzer)', ''];
468
+ for (const item of institutionalCitations) {
469
+ const meta = [item.kind];
470
+ if (item.author) meta.push(`@${item.author}`);
471
+ if (item.timestamp) meta.push(day(item.timestamp));
472
+ if (item.url) meta.push(item.url);
473
+ out.push(`- **${item.title}** (${meta.join(' · ')}):`);
474
+ if (item.snippet) {
475
+ out.push(` > ${item.snippet.replace(/\n+/g, ' ')}`);
476
+ }
477
+ if (item.refs && item.refs.length) {
478
+ out.push(` *Refs: ${item.refs.join(', ')}*`);
479
+ }
480
+ }
481
+ if (omitted?.citations) {
482
+ out.push(
483
+ '',
484
+ `_${omitted.citations} institutional citation(s) not shown to fit the token budget._`
485
+ );
486
+ }
487
+ return out;
488
+ }
489
+
410
490
  function markdownOwnership({ ownership, omitted }) {
411
491
  if (!ownership || !ownership.authors.length) return [];
412
492
  const scope =
@@ -427,7 +507,7 @@ function markdownOwnership({ ownership, omitted }) {
427
507
  `| ${name} | ${author.lines} | ${percent(author.share)} | ${author.commits} | ${day(author.lastTouched)} |`
428
508
  );
429
509
  }
430
- if (omitted.ownership) out.push(`| … ${omitted.ownership} more | | | | |`);
510
+ if (omitted.ownership) out.push(`| \u2026 ${omitted.ownership} more | | | | |`);
431
511
  return out;
432
512
  }
433
513
 
@@ -452,7 +532,7 @@ function markdownCoupling({ coupling, target, omitted }) {
452
532
  const gone = file.exists === false ? ' (no longer in the tree)' : '';
453
533
  out.push(`| ${code(file.path)}${gone} | ${file.count} | ${percent(file.confidence)} |`);
454
534
  }
455
- if (omitted.coupling) out.push(`| … ${omitted.coupling} more | | |`);
535
+ if (omitted.coupling) out.push(`| \u2026 ${omitted.coupling} more | | |`);
456
536
  return out;
457
537
  }
458
538
 
@@ -534,6 +614,13 @@ function attributes(values) {
534
614
  function renderXml(data, budget) {
535
615
  const { target, history } = data;
536
616
  const out = ['<why_context generator="ctxman why">'];
617
+ if (Array.isArray(data.repos) && data.repos.length > 0) {
618
+ out.push('<repositories>');
619
+ for (const repo of data.repos) {
620
+ out.push(`<repo${attributes({ alias: repo.alias, root: repo.root })}/>`);
621
+ }
622
+ out.push('</repositories>');
623
+ }
537
624
  if (Object.keys(data.omitted).length) {
538
625
  out.push(`<trimmed${attributes({ budget, ...data.omitted })}/>`);
539
626
  }
@@ -617,6 +704,64 @@ function renderXml(data, budget) {
617
704
  }
618
705
  out.push('</references>');
619
706
 
707
+ if ((data.discussions?.length ?? 0) > 0) {
708
+ out.push('<discussions>');
709
+ for (const item of data.discussions) {
710
+ out.push(
711
+ `<discussion${attributes({
712
+ id: item.id,
713
+ number: item.number,
714
+ repo: item.repo,
715
+ provider: item.provider,
716
+ kind: item.kind,
717
+ state: item.state,
718
+ author: item.author,
719
+ created_at: item.createdAt,
720
+ url: item.url,
721
+ labels: item.labels,
722
+ commits: item.commits,
723
+ })}>`
724
+ );
725
+ if (item.title) out.push(`<title>${xml(item.title)}</title>`);
726
+ if (item.body) out.push(`<body>${xml(item.body)}</body>`);
727
+ for (const comment of item.comments ?? []) {
728
+ out.push(
729
+ `<comment${attributes({
730
+ author: comment.author,
731
+ created_at: comment.createdAt,
732
+ })}>${xml(comment.body)}</comment>`
733
+ );
734
+ }
735
+ out.push('</discussion>');
736
+ }
737
+ out.push('</discussions>');
738
+ }
739
+
740
+ if ((data.institutionalCitations?.length ?? 0) > 0) {
741
+ out.push('<institutional_citations>');
742
+ for (const item of data.institutionalCitations) {
743
+ out.push(
744
+ `<citation${attributes({
745
+ id: item.id,
746
+ kind: item.kind,
747
+ title: item.title,
748
+ url: item.url,
749
+ author: item.author,
750
+ timestamp: item.timestamp,
751
+ score: item.score,
752
+ })}>`
753
+ );
754
+ if (item.snippet) out.push(`<snippet>${xml(item.snippet)}</snippet>`);
755
+ if (item.refs?.length) {
756
+ for (const ref of item.refs) {
757
+ out.push(`<ref>${xml(ref)}</ref>`);
758
+ }
759
+ }
760
+ out.push('</citation>');
761
+ }
762
+ out.push('</institutional_citations>');
763
+ }
764
+
620
765
  if (data.ownership) {
621
766
  const own = data.ownership;
622
767
  out.push(
@@ -81,37 +81,39 @@ class ConfigGenerator {
81
81
  }
82
82
 
83
83
  const filePath = path.join(this.projectRoot, filename);
84
- const exists = fs.existsSync(filePath);
85
-
86
- // Check if should skip
87
- if (exists && !force) {
84
+ const skip = () =>
88
85
  result.skipped.push({
89
86
  type,
90
87
  filename,
91
88
  reason: 'File already exists (use --force to overwrite)',
92
89
  });
93
- continue;
94
- }
95
-
96
- // Generate file content
97
- const content = this.generateFileContent(type, patterns);
98
90
 
99
91
  if (dryRun) {
100
- if (exists) {
101
- result.overwritten.push({ type, filename, content });
102
- } else {
103
- result.created.push({ type, filename, content });
92
+ const exists = fs.existsSync(filePath);
93
+ if (exists && !force) {
94
+ skip();
95
+ continue;
104
96
  }
97
+ const content = this.generateFileContent(type, patterns);
98
+ (exists ? result.overwritten : result.created).push({ type, filename, content });
105
99
  continue;
106
100
  }
107
101
 
108
- // Write file
102
+ const content = this.generateFileContent(type, patterns);
103
+ // Create exclusively first, so an existing file is found by the write itself rather
104
+ // than by a separate check that could be out of date
109
105
  try {
110
- fs.writeFileSync(filePath, content, 'utf-8');
111
- if (exists) {
112
- result.overwritten.push({ type, filename });
113
- } else {
106
+ try {
107
+ fs.writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'wx' });
114
108
  result.created.push({ type, filename });
109
+ } catch (error) {
110
+ if (error.code !== 'EEXIST') throw error;
111
+ if (!force) {
112
+ skip();
113
+ continue;
114
+ }
115
+ fs.writeFileSync(filePath, content, 'utf-8');
116
+ result.overwritten.push({ type, filename });
115
117
  }
116
118
  } catch (error) {
117
119
  result.errors.push({
@@ -514,7 +514,8 @@ class ProjectDetector {
514
514
  * @returns {boolean} True if matches
515
515
  */
516
516
  matchesGlob(filename, pattern) {
517
- const regex = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
517
+ // Every regex character is literal except `*`, which matches any run of characters
518
+ const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
518
519
  return new RegExp(`^${regex}$`).test(filename);
519
520
  }
520
521