ctxman 3.0.0 → 3.1.1

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 (224) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +381 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -67
  18. package/bin/cm-audit.js +121 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +687 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +772 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +116 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +312 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +53 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/ask.js +122 -0
  108. package/lib/rag/sources/FileDataSource.js +12 -9
  109. package/lib/rag/stores/BaseVectorStore.js +5 -5
  110. package/lib/rag/stores/ChromaDBStore.js +10 -10
  111. package/lib/rag/stores/LanceDBStore.js +29 -19
  112. package/lib/rag/stores/PineconeStore.js +11 -9
  113. package/lib/search/bm25.js +433 -0
  114. package/lib/search/code-units.js +201 -0
  115. package/lib/search/symbols.js +189 -0
  116. package/lib/ui/dashboard.js +120 -89
  117. package/lib/ui/index.js +1 -6
  118. package/lib/ui/init-wizard-ui.js +550 -0
  119. package/lib/ui/progress-bar.js +94 -88
  120. package/lib/ui/select-input.js +40 -32
  121. package/lib/ui/template-selector.js +99 -0
  122. package/lib/ui/wizard.js +374 -329
  123. package/lib/utils/claude-token-counter.js +151 -0
  124. package/lib/utils/cli-output.js +17 -0
  125. package/lib/utils/clipboard-utils.js +49 -50
  126. package/lib/utils/config-utils.js +81 -66
  127. package/lib/utils/context-versioning.js +532 -0
  128. package/lib/utils/error-handler.js +112 -112
  129. package/lib/utils/file-utils.js +153 -69
  130. package/lib/utils/format-converter.js +356 -283
  131. package/lib/utils/git-utils.js +366 -294
  132. package/lib/utils/llm-cost-estimator.js +281 -0
  133. package/lib/utils/llm-detector.js +242 -78
  134. package/lib/utils/logger.js +246 -237
  135. package/lib/utils/messages.js +355 -0
  136. package/lib/utils/multi-repo-manager.js +364 -0
  137. package/lib/utils/profile-manager.js +380 -0
  138. package/lib/utils/safe-read.js +61 -0
  139. package/lib/utils/secret-redactor.js +61 -0
  140. package/lib/utils/snapshot-manager.js +679 -0
  141. package/lib/utils/template-manager.js +378 -0
  142. package/lib/utils/token-utils.js +201 -170
  143. package/lib/utils/tokenizer-adapter.js +421 -377
  144. package/lib/utils/updater.js +539 -506
  145. package/lib/watch/FileWatcher.js +12 -8
  146. package/lib/watch/IncrementalAnalyzer.js +7 -7
  147. package/lib/why/coupling.js +81 -0
  148. package/lib/why/errors.js +23 -0
  149. package/lib/why/git.js +169 -0
  150. package/lib/why/history.js +108 -0
  151. package/lib/why/index.js +720 -0
  152. package/lib/why/ownership.js +79 -0
  153. package/lib/why/paths.js +207 -0
  154. package/lib/why/references.js +259 -0
  155. package/lib/why/render.js +722 -0
  156. package/lib/why/search.js +358 -0
  157. package/lib/why/target.js +389 -0
  158. package/lib/wizards/config-generator.js +257 -0
  159. package/lib/wizards/project-detector.js +572 -0
  160. package/package.json +44 -10
  161. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  224. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Export audit log
3
+ *
4
+ * One JSON object per line, appended for every export (and every export the policy
5
+ * refused) while the policy enables `audit`. A record names the files and carries a
6
+ * SHA-256 of the exported text, never the text itself.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+ import { execFileSync } from 'child_process';
14
+ import { createRequire } from 'module';
15
+ import { relativePathOf, loadPolicy, DEFAULT_AUDIT_PATH } from './policy.js';
16
+
17
+ const { version: CTXMAN_VERSION } = createRequire(import.meta.url)('../../package.json');
18
+
19
+ const defaultWarn = (message) => console.warn(message);
20
+
21
+ /**
22
+ * SHA-256 of exported text; several parts (digest chunks) hash as their concatenation
23
+ * @param {string|Buffer|Array<string|Buffer>} text
24
+ * @returns {string} Hex digest
25
+ */
26
+ export function sha256(text) {
27
+ const hash = crypto.createHash('sha256');
28
+ for (const part of Array.isArray(text) ? text : [text]) hash.update(part);
29
+ return hash.digest('hex');
30
+ }
31
+
32
+ /**
33
+ * Commit checked out in `dir`, or null outside a git repository
34
+ * @param {string} dir
35
+ * @returns {string|null}
36
+ */
37
+ export function getGitHead(dir) {
38
+ try {
39
+ if (!dir || !fs.statSync(dir).isDirectory()) return null;
40
+ const head = execFileSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], {
41
+ cwd: dir,
42
+ encoding: 'utf8',
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ timeout: 5000,
45
+ }).trim();
46
+ return /^[0-9a-f]{40,64}$/.test(head) ? head : null;
47
+ } catch (_error) {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function currentUser() {
53
+ try {
54
+ return os.userInfo().username;
55
+ } catch (_error) {
56
+ // No passwd entry (some containers)
57
+ return process.env.USER || process.env.USERNAME || 'unknown';
58
+ }
59
+ }
60
+
61
+ function countRedactions(redactions) {
62
+ if (typeof redactions === 'number') return redactions;
63
+ if (redactions && typeof redactions === 'object') {
64
+ return Object.values(redactions).reduce((sum, count) => sum + (Number(count) || 0), 0);
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /**
70
+ * Build the audit record for an export event
71
+ * @param {import('./policy.js').Policy} policy
72
+ * @param {AuditEvent} event
73
+ * @returns {object}
74
+ */
75
+ export function buildAuditRecord(policy, event = {}) {
76
+ const files = (event.files ?? []).map(relativePathOf);
77
+ const tokens =
78
+ typeof event.tokens === 'number'
79
+ ? event.tokens
80
+ : (event.files ?? []).reduce((sum, file) => sum + (Number(file?.tokens) || 0), 0);
81
+ const refused = event.outcome === 'refused';
82
+ const root = event.root ?? policy.root;
83
+
84
+ let digest = null;
85
+ if (!refused) {
86
+ if (event.sha256) digest = event.sha256;
87
+ else if (event.text !== undefined && event.text !== null) digest = sha256(event.text);
88
+ }
89
+
90
+ const record = {
91
+ id: crypto.randomUUID(),
92
+ timestamp: new Date().toISOString(),
93
+ outcome: refused ? 'refused' : 'exported',
94
+ command: event.command ?? null,
95
+ root,
96
+ user: currentUser(),
97
+ host: os.hostname(),
98
+ gitHead: event.gitHead !== undefined ? event.gitHead : getGitHead(root),
99
+ targetModel: event.targetModel ?? null,
100
+ format: event.format ?? null,
101
+ destination: event.destination ?? null,
102
+ fileCount: files.length,
103
+ totalTokens: tokens,
104
+ redactions: countRedactions(event.redactions),
105
+ removedByPolicy: event.removedByPolicy ?? 0,
106
+ files,
107
+ sha256: digest,
108
+ ctxmanVersion: CTXMAN_VERSION,
109
+ };
110
+ if (Array.isArray(event.outputFiles) && event.outputFiles.length > 0) {
111
+ record.outputFiles = event.outputFiles;
112
+ }
113
+ if (refused) {
114
+ record.violations = (event.violations ?? []).map(({ rule, message }) => ({ rule, message }));
115
+ }
116
+ return record;
117
+ }
118
+
119
+ /**
120
+ * @typedef {object} AuditEvent
121
+ * @property {string} command - What exported, e.g. "cli --context-export", "mcp generate_context"
122
+ * @property {string} [root] - Exported project (default: the policy root)
123
+ * @property {string|null} [targetModel]
124
+ * @property {string|null} [format] - Output format or style (json, toon, gitingest, xml, ...)
125
+ * @property {string} destination - "stdout", "clipboard", "mcp", "rest" or a file path
126
+ * @property {string[]} [outputFiles] - Every file written, when there are several (chunks)
127
+ * @property {Array<string|{relativePath?: string, path?: string, tokens?: number}>} [files]
128
+ * @property {number} [tokens] - Token total (default: sum of files[].tokens)
129
+ * @property {number|Object<string, number>} [redactions] - Count, or counts per secret type
130
+ * @property {number} [removedByPolicy] - Files dropped by the deny rules
131
+ * @property {string|Buffer|Array<string|Buffer>} [text] - Exported text; only its hash is kept
132
+ * @property {string} [sha256] - Precomputed hash instead of `text`
133
+ * @property {string|null} [gitHead] - Override the commit (default: HEAD of `root`)
134
+ * @property {'exported'|'refused'} [outcome]
135
+ * @property {Array<{rule: string, message: string}>} [violations] - For refused exports
136
+ */
137
+
138
+ /**
139
+ * Append an export event to the policy's audit log(s). Does nothing unless the policy
140
+ * enables `audit`. Never throws: a log that cannot be written produces a warning.
141
+ * @param {import('./policy.js').Policy} policy - From loadPolicy()
142
+ * @param {AuditEvent} event
143
+ * @param {object} [options]
144
+ * @param {(message: string) => void} [options.warn=console.warn]
145
+ * @returns {object|null} The record written, or null when auditing is off
146
+ */
147
+ export function recordExport(policy, event = {}, { warn = defaultWarn } = {}) {
148
+ if (!policy?.audit?.enabled) return null;
149
+
150
+ let record;
151
+ try {
152
+ record = buildAuditRecord(policy, event);
153
+ } catch (error) {
154
+ warn(`⚠️ Could not build the ctxman audit record: ${error.message}`);
155
+ return null;
156
+ }
157
+
158
+ const line = `${JSON.stringify(record)}\n`;
159
+ for (const file of policy.audit.paths) {
160
+ try {
161
+ fs.mkdirSync(path.dirname(file), { recursive: true });
162
+ fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600 });
163
+ } catch (error) {
164
+ warn(`⚠️ Could not write the ctxman audit log ${file}: ${error.message}`);
165
+ }
166
+ }
167
+ return record;
168
+ }
169
+
170
+ /**
171
+ * The audit logs of a project's policy, or the default location when the policy names none
172
+ * @param {string} root - Project root
173
+ * @param {Object} [options]
174
+ * @param {object} [options.env=process.env] - Environment for loading the policy
175
+ * @returns {{files: string[], error: Error|null}} `error` when the policy could not be
176
+ * loaded (the default location is returned then)
177
+ */
178
+ export function auditLogFiles(root, { env = process.env } = {}) {
179
+ try {
180
+ const policy = loadPolicy(root, { env });
181
+ if (policy.audit.paths.length > 0) return { files: policy.audit.paths, error: null };
182
+ return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error: null };
183
+ } catch (error) {
184
+ return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Read audit logs. Lines that are not JSON objects are skipped and counted; events that
190
+ * appear in several logs (organisation and project) are listed once.
191
+ * @param {string[]} files
192
+ * @returns {{events: object[], skipped: number, missing: string[]}} Events oldest first
193
+ */
194
+ export function readAuditLog(files) {
195
+ const events = [];
196
+ const seen = new Set();
197
+ const missing = [];
198
+ let skipped = 0;
199
+
200
+ for (const file of files) {
201
+ let text;
202
+ try {
203
+ text = fs.readFileSync(file, 'utf8');
204
+ } catch (_error) {
205
+ missing.push(file);
206
+ continue;
207
+ }
208
+
209
+ for (const line of text.split('\n')) {
210
+ if (!line.trim()) continue;
211
+ let event;
212
+ try {
213
+ event = JSON.parse(line);
214
+ } catch (_error) {
215
+ skipped++;
216
+ continue;
217
+ }
218
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
219
+ skipped++;
220
+ continue;
221
+ }
222
+ if (event.id && seen.has(event.id)) continue;
223
+ if (event.id) seen.add(event.id);
224
+ events.push(event);
225
+ }
226
+ }
227
+
228
+ events.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
229
+ return { events, skipped, missing };
230
+ }
231
+
232
+ /**
233
+ * Parse a --since value: a date ("2026-10-01", ISO timestamp) or an age ("30m", "12h",
234
+ * "7d", "2w")
235
+ * @param {string} value
236
+ * @param {Date} [now]
237
+ * @returns {Date}
238
+ * @throws {Error} For anything else
239
+ */
240
+ export function parseSince(value, now = new Date()) {
241
+ const relative = /^(\d+)\s*([mhdw])$/i.exec(String(value).trim());
242
+ if (relative) {
243
+ const unit = { m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }[relative[2].toLowerCase()];
244
+ return new Date(now.getTime() - Number(relative[1]) * unit);
245
+ }
246
+ const time = Date.parse(value);
247
+ if (Number.isNaN(time)) {
248
+ throw new Error(
249
+ `Invalid --since value ${JSON.stringify(value)}: use a date (2026-10-01) or an age (7d, 12h)`
250
+ );
251
+ }
252
+ return new Date(time);
253
+ }
254
+
255
+ /**
256
+ * Keep events at or after `since`, then the last `limit` of them
257
+ * @param {object[]} events - Oldest first
258
+ * @param {{since?: Date|null, limit?: number|null}} [options]
259
+ * @returns {object[]}
260
+ */
261
+ export function filterAuditEvents(events, { since = null, limit = null } = {}) {
262
+ let result = events;
263
+ if (since) {
264
+ result = result.filter((event) => Date.parse(event.timestamp) >= since.getTime());
265
+ }
266
+ if (limit && limit > 0 && result.length > limit) result = result.slice(-limit);
267
+ return result;
268
+ }
269
+
270
+ const fit = (value, width, { keepEnd = false } = {}) => {
271
+ const text = value === null || value === undefined || value === '' ? '-' : String(value);
272
+ if (text.length <= width) return text.padEnd(width);
273
+ return keepEnd ? `…${text.slice(-(width - 1))}` : `${text.slice(0, width - 1)}…`;
274
+ };
275
+
276
+ // A file destination inside the exported project is shown relative to it
277
+ const destinationOf = (event) => {
278
+ const destination = event.destination;
279
+ if (typeof destination !== 'string' || typeof event.root !== 'string') return destination;
280
+ const relative = path.relative(event.root, destination);
281
+ return path.isAbsolute(destination) && relative && !relative.startsWith('..')
282
+ ? relative
283
+ : destination;
284
+ };
285
+
286
+ /**
287
+ * Render events as a fixed-width table
288
+ * @param {object[]} events
289
+ * @returns {string}
290
+ */
291
+ export function formatAuditTable(events) {
292
+ const columns = [
293
+ ['TIME (UTC)', 20, (e) => String(e.timestamp ?? '').replace(/\.\d+Z$/, 'Z')],
294
+ ['RESULT', 8, (e) => e.outcome],
295
+ ['USER', 12, (e) => e.user],
296
+ ['COMMAND', 26, (e) => e.command],
297
+ ['DESTINATION', 28, destinationOf, { keepEnd: true }],
298
+ ['MODEL', 16, (e) => e.targetModel],
299
+ ['FILES', 6, (e) => e.fileCount],
300
+ ['TOKENS', 10, (e) => (typeof e.totalTokens === 'number' ? e.totalTokens : null)],
301
+ ['REDACT', 6, (e) => e.redactions],
302
+ ['SHA256', 12, (e) => (e.sha256 ? e.sha256.slice(0, 12) : null)],
303
+ ];
304
+
305
+ const header = columns.map(([title, width]) => fit(title, width)).join(' ');
306
+ const rows = events.map((event) =>
307
+ columns.map(([, width, get, options]) => fit(get(event), width, options)).join(' ')
308
+ );
309
+ return [header, '-'.repeat(header.length), ...rows].map((line) => line.trimEnd()).join('\n');
310
+ }
311
+
312
+ export default { recordExport, readAuditLog, formatAuditTable, sha256 };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Export enforcement: checks an export request against the effective policy
3
+ */
4
+
5
+ import { recordExport } from './audit.js';
6
+ import { relativePathOf } from './policy.js';
7
+
8
+ /** Exit code of a CLI export the policy refused */
9
+ export const POLICY_VIOLATION_EXIT_CODE = 3;
10
+
11
+ /**
12
+ * An export the policy does not allow
13
+ */
14
+ export class PolicyViolationError extends Error {
15
+ /**
16
+ * @param {Array<{rule: string, message: string, sources: string[]}>} violations
17
+ */
18
+ constructor(violations) {
19
+ super(
20
+ `Export refused by the ctxman policy:\n${violations.map((v) => ` - ${v.message}`).join('\n')}`
21
+ );
22
+ this.name = 'PolicyViolationError';
23
+ this.code = 'POLICY_VIOLATION';
24
+ this.violations = violations;
25
+ this.statusCode = 403;
26
+ this.exitCode = POLICY_VIOLATION_EXIT_CODE;
27
+ }
28
+ }
29
+
30
+ const formatNumber = (value) => value.toLocaleString('en-US');
31
+ const inFiles = (files) => (files.length > 0 ? ` (set in ${files.join(', ')})` : '');
32
+
33
+ /**
34
+ * Check an export against the policy. Callers remove denied files before exporting; a
35
+ * denied file still in `files` is reported as a violation.
36
+ * @param {import('./policy.js').Policy} policy - From loadPolicy()
37
+ * @param {object} request
38
+ * @param {Array<string|{relativePath?: string, path?: string, tokens?: number}>} [request.files]
39
+ * Files in the export
40
+ * @param {number} [request.tokens] - Token total of the export (default: sum of files[].tokens)
41
+ * @param {boolean} [request.redact=true] - False when secret redaction was turned off (--no-redact)
42
+ * @param {string|null} [request.targetModel] - Model named with --target-model, if any
43
+ * @throws {PolicyViolationError} Listing every rule the export breaks
44
+ */
45
+ export function enforceExport(
46
+ policy,
47
+ { files = [], tokens, redact = true, targetModel = null } = {}
48
+ ) {
49
+ if (!policy?.active) return;
50
+
51
+ const violations = [];
52
+
53
+ const deniedSources = new Set();
54
+ let denied = 0;
55
+ for (const file of files) {
56
+ const source = policy.deniedBy(relativePathOf(file));
57
+ if (source) {
58
+ denied++;
59
+ deniedSources.add(source);
60
+ }
61
+ }
62
+ if (denied > 0) {
63
+ violations.push({
64
+ rule: 'deny',
65
+ message: `${denied} file${denied === 1 ? '' : 's'} in the export ${denied === 1 ? 'matches' : 'match'} the policy's deny rules and can never be exported${inFiles([...deniedSources])}`,
66
+ sources: [...deniedSources],
67
+ });
68
+ }
69
+
70
+ if (redact === false && policy.requireRedaction) {
71
+ violations.push({
72
+ rule: 'requireRedaction',
73
+ message: `secret redaction is required, so --no-redact is refused${inFiles(policy.requireRedactionSources)}`,
74
+ sources: policy.requireRedactionSources,
75
+ });
76
+ }
77
+
78
+ const total =
79
+ typeof tokens === 'number'
80
+ ? tokens
81
+ : files.reduce((sum, file) => sum + (Number(file?.tokens) || 0), 0);
82
+ if (policy.maxExportTokens !== null && total > policy.maxExportTokens) {
83
+ violations.push({
84
+ rule: 'maxExportTokens',
85
+ message: `the export has ${formatNumber(total)} tokens, over the limit of ${formatNumber(policy.maxExportTokens)}${inFiles([policy.maxExportTokensSource])}; narrow it with .contextinclude, --changed-only or a template`,
86
+ sources: [policy.maxExportTokensSource],
87
+ });
88
+ }
89
+
90
+ if (targetModel && !policy.isModelAllowed(targetModel)) {
91
+ const rejecting = policy.modelRules.filter(
92
+ (rule) => !rule.regexes.some((regex) => regex.test(targetModel))
93
+ );
94
+ const allowed = rejecting.map((rule) => rule.patterns.join(', ')).join('; and ');
95
+ violations.push({
96
+ rule: 'allowedTargetModels',
97
+ message: `target model "${targetModel}" is not allowed (allowed: ${allowed})${inFiles(rejecting.map((rule) => rule.file))}`,
98
+ sources: rejecting.map((rule) => rule.file),
99
+ });
100
+ }
101
+
102
+ if (violations.length > 0) throw new PolicyViolationError(violations);
103
+ }
104
+
105
+ /**
106
+ * enforceExport() followed by recordExport(): the usual call right before an export is
107
+ * handed out. A refused export is logged with outcome "refused" and the error re-thrown.
108
+ * @param {import('./policy.js').Policy} policy
109
+ * @param {import('./audit.js').AuditEvent & {redact?: boolean}} event
110
+ * @param {object} [options] - Passed to recordExport()
111
+ * @returns {object|null} The audit record, or null when auditing is off
112
+ * @throws {PolicyViolationError}
113
+ */
114
+ export function guardExport(policy, event, options) {
115
+ if (!policy?.active) return null;
116
+ try {
117
+ enforceExport(policy, event);
118
+ } catch (error) {
119
+ if (error instanceof PolicyViolationError) {
120
+ recordExport(policy, { ...event, outcome: 'refused', violations: error.violations }, options);
121
+ }
122
+ throw error;
123
+ }
124
+ return recordExport(policy, event, options);
125
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * AI-tool ignore files
3
+ *
4
+ * Other coding assistants keep their own ignore file next to .gitignore. A team that already
5
+ * lists its sensitive paths there can have ctxman honour the same lists instead of copying
6
+ * them into .contextignore. All of them use .gitignore syntax, so the existing parser reads
7
+ * them; only the files in the project root are read.
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import GitIgnoreParser from '../parsers/gitignore-parser.js';
13
+
14
+ /**
15
+ * Known ignore files, with the tool that reads each one
16
+ * @type {ReadonlyArray<{file: string, tool: string, emptyMeansAll?: boolean}>}
17
+ */
18
+ export const KNOWN_IGNORE_FILES = Object.freeze([
19
+ { file: '.cursorignore', tool: 'Cursor' },
20
+ { file: '.cursorindexingignore', tool: 'Cursor (indexing only)' },
21
+ // Gemini Code Assist / Android Studio: an empty .aiexclude blocks its whole directory
22
+ { file: '.aiexclude', tool: 'Gemini Code Assist', emptyMeansAll: true },
23
+ { file: '.geminiignore', tool: 'Gemini CLI' },
24
+ { file: '.augmentignore', tool: 'Augment Code' },
25
+ { file: '.tabnineignore', tool: 'Tabnine' },
26
+ { file: '.devinignore', tool: 'Devin' },
27
+ { file: '.codeiumignore', tool: 'Windsurf (Codeium)' },
28
+ { file: '.aiderignore', tool: 'Aider' },
29
+ { file: '.clineignore', tool: 'Cline' },
30
+ { file: '.rooignore', tool: 'Roo Code' },
31
+ { file: '.continueignore', tool: 'Continue' },
32
+ { file: '.repomixignore', tool: 'Repomix' },
33
+ { file: '.gitingestignore', tool: 'Gitingest' },
34
+ ]);
35
+
36
+ const KNOWN_BY_NAME = new Map(KNOWN_IGNORE_FILES.map((entry) => [entry.file, entry]));
37
+
38
+ /**
39
+ * Whether a name can be used as an ignore file: a bare file name in the project root
40
+ * @param {string} name
41
+ * @returns {boolean}
42
+ */
43
+ export function isValidIgnoreFileName(name) {
44
+ return (
45
+ typeof name === 'string' &&
46
+ name.length > 0 &&
47
+ name !== '.' &&
48
+ name !== '..' &&
49
+ !/[/\\\0]/.test(name)
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Turn a --respect-ignore-files value or a policy `ignoreFiles` list into file names.
55
+ * "all" stands for every known file; names are deduplicated and keep their order.
56
+ * @param {string|string[]|null|undefined} spec - "all", "a,b" or ["a", "b"]
57
+ * @returns {string[]}
58
+ * @throws {Error} For a name with a path separator or `..`
59
+ */
60
+ export function resolveIgnoreFileNames(spec) {
61
+ if (spec === null || spec === undefined || spec === false) return [];
62
+
63
+ const items = (Array.isArray(spec) ? spec : String(spec).split(','))
64
+ .map((item) => String(item).trim())
65
+ .filter(Boolean);
66
+
67
+ const names = [];
68
+ for (const item of items) {
69
+ const expanded = item === 'all' ? KNOWN_IGNORE_FILES.map((entry) => entry.file) : [item];
70
+ for (const name of expanded) {
71
+ if (!isValidIgnoreFileName(name)) {
72
+ throw new Error(
73
+ `Invalid ignore file name ${JSON.stringify(name)}: expected a file name in the project root, such as .cursorignore, or "all"`
74
+ );
75
+ }
76
+ if (!names.includes(name)) names.push(name);
77
+ }
78
+ }
79
+ return names;
80
+ }
81
+
82
+ /**
83
+ * Patterns of the given ignore files in `root`. Each file is applied on its own: a path is
84
+ * ignored when any of the files ignores it, and a `!` line only re-includes paths matched
85
+ * earlier in the same file.
86
+ * @param {string} root - Project root
87
+ * @param {string[]} names - File names from resolveIgnoreFileNames()
88
+ * @returns {IgnoreFileRules}
89
+ */
90
+ export function loadIgnoreFileRules(root, names) {
91
+ const parser = new GitIgnoreParser(null, null, null);
92
+ const files = names.map((name) => {
93
+ const filePath = path.join(root, name);
94
+ const known = KNOWN_BY_NAME.get(name);
95
+ const exists = isFile(filePath);
96
+ let patterns = exists ? parser.parsePatternFile(filePath) : [];
97
+ if (exists && patterns.length === 0 && known?.emptyMeansAll) {
98
+ patterns = [parser.convertToRegex('**')];
99
+ }
100
+ return { name, path: filePath, tool: known?.tool ?? null, exists, patterns };
101
+ });
102
+
103
+ return new IgnoreFileRules(parser, files);
104
+ }
105
+
106
+ function isFile(filePath) {
107
+ try {
108
+ return fs.statSync(filePath).isFile();
109
+ } catch (_error) {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Loaded ignore files and the matcher over them
116
+ */
117
+ export class IgnoreFileRules {
118
+ /**
119
+ * @param {GitIgnoreParser} parser - Supplies the pattern matching
120
+ * @param {Array<{name: string, path: string, tool: string|null, exists: boolean, patterns: object[]}>} files
121
+ */
122
+ constructor(parser, files) {
123
+ this.parser = parser;
124
+ this.files = files;
125
+ }
126
+
127
+ /** @returns {boolean} True when at least one of the files exists */
128
+ get active() {
129
+ return this.files.some((file) => file.exists);
130
+ }
131
+
132
+ /**
133
+ * @param {string} relativePath - Path relative to the project root (file or directory)
134
+ * @returns {string|null} Name of the first ignore file that ignores the path, or null
135
+ */
136
+ match(relativePath) {
137
+ const rel = relativePath.split(path.sep).join('/');
138
+ for (const file of this.files) {
139
+ if (file.patterns.length > 0 && this.parser.testPatterns(file.patterns, rel, file.name)) {
140
+ return file.name;
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * @param {string} relativePath
148
+ * @returns {boolean}
149
+ */
150
+ isIgnored(relativePath) {
151
+ return this.match(relativePath) !== null;
152
+ }
153
+
154
+ /**
155
+ * One line per file for console output, e.g. ".cursorignore (3 patterns)"
156
+ * @returns {string}
157
+ */
158
+ describe() {
159
+ return this.files
160
+ .map((file) =>
161
+ file.exists
162
+ ? `${file.name} (${file.patterns.length} pattern${file.patterns.length === 1 ? '' : 's'})`
163
+ : `${file.name} (not found)`
164
+ )
165
+ .join(', ');
166
+ }
167
+ }
168
+
169
+ export default { KNOWN_IGNORE_FILES, resolveIgnoreFileNames, loadIgnoreFileRules };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Export policy and audit log
3
+ *
4
+ * The calls an exporting command makes:
5
+ *
6
+ * const policy = loadPolicy(root); // once, before scanning
7
+ * const scanner = new Scanner(root, { policy }); // drops denied files
8
+ * ...build the export...
9
+ * guardExport(policy, { command, destination, format, targetModel, files, tokens,
10
+ * redact, redactions, text }); // throws PolicyViolationError
11
+ * ...write / send the export...
12
+ *
13
+ * guardExport() is enforceExport() plus recordExport(); call the two separately when the
14
+ * export must be checked before it is built.
15
+ */
16
+
17
+ export {
18
+ loadPolicy,
19
+ Policy,
20
+ PolicyConfigError,
21
+ validatePolicyDocument,
22
+ createStarterPolicy,
23
+ normalizeRelativePath,
24
+ relativePathOf,
25
+ modelGlobToRegex,
26
+ POLICY_FILE,
27
+ POLICY_ENV,
28
+ DEFAULT_AUDIT_PATH,
29
+ POLICY_DOCS_URL,
30
+ } from './policy.js';
31
+ export {
32
+ enforceExport,
33
+ guardExport,
34
+ PolicyViolationError,
35
+ POLICY_VIOLATION_EXIT_CODE,
36
+ } from './enforce.js';
37
+ export {
38
+ recordExport,
39
+ buildAuditRecord,
40
+ readAuditLog,
41
+ auditLogFiles,
42
+ filterAuditEvents,
43
+ formatAuditTable,
44
+ parseSince,
45
+ getGitHead,
46
+ sha256,
47
+ } from './audit.js';
48
+ export {
49
+ KNOWN_IGNORE_FILES,
50
+ resolveIgnoreFileNames,
51
+ loadIgnoreFileRules,
52
+ IgnoreFileRules,
53
+ } from './ignore-files.js';