ctxman 3.0.0 → 3.1.0

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 (223) 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 +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -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 +683 -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 +675 -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 +113 -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 +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -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/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -0,0 +1,489 @@
1
+ /**
2
+ * Export policy: loading, validation and merging
3
+ *
4
+ * A project keeps its policy in .ctxman/policy.json; an organisation can add one more file
5
+ * through the CTXMAN_POLICY environment variable. Both apply at once and the stricter value
6
+ * wins: deny lists are combined, limits take the lowest value, and a target model must be
7
+ * allowed by every file that restricts models. This is a repository/organisation control
8
+ * against accidental exports, not a security boundary against the local user.
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import GitIgnoreParser from '../parsers/gitignore-parser.js';
14
+ import { isValidIgnoreFileName, resolveIgnoreFileNames } from './ignore-files.js';
15
+
16
+ /** Project policy file, relative to the project root */
17
+ export const POLICY_FILE = '.ctxman/policy.json';
18
+ /** Environment variable naming the organisation policy file */
19
+ export const POLICY_ENV = 'CTXMAN_POLICY';
20
+ /** Audit log used when `audit.path` is not set, relative to the project root */
21
+ export const DEFAULT_AUDIT_PATH = '.ctxman/audit.log';
22
+ export const POLICY_DOCS_URL =
23
+ 'https://github.com/hakkisagdic/ctxman/blob/main/docs/commands/policy.md';
24
+
25
+ const POLICY_VERSION = 1;
26
+ const FIELDS = [
27
+ 'version',
28
+ 'deny',
29
+ 'requireRedaction',
30
+ 'maxExportTokens',
31
+ 'allowedTargetModels',
32
+ 'ignoreFiles',
33
+ 'audit',
34
+ ];
35
+ const AUDIT_FIELDS = ['enabled', 'path'];
36
+
37
+ /**
38
+ * A policy file that cannot be read or does not match the schema
39
+ */
40
+ export class PolicyConfigError extends Error {
41
+ /**
42
+ * @param {string} file - Policy file
43
+ * @param {Array<{field: string|null, message: string}>} errors
44
+ */
45
+ constructor(file, errors) {
46
+ const describe = ({ field, message }) => (field ? `"${field}" ${message}` : message);
47
+ const detail =
48
+ errors.length === 1
49
+ ? describe(errors[0])
50
+ : errors.map((error) => `\n - ${describe(error)}`).join('');
51
+ super(`Invalid ctxman policy ${file}: ${detail}`);
52
+ this.name = 'PolicyConfigError';
53
+ this.code = 'POLICY_CONFIG';
54
+ this.file = file;
55
+ this.field = errors[0]?.field ?? null;
56
+ this.errors = errors;
57
+ // A broken policy is a server-side configuration problem, and a CLI usage error
58
+ this.statusCode = 500;
59
+ this.exitCode = 2;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Normalize a path for matching: forward slashes, no leading "./" or "/"
65
+ * @param {string} relativePath
66
+ * @returns {string}
67
+ */
68
+ export function normalizeRelativePath(relativePath) {
69
+ return String(relativePath ?? '')
70
+ .replace(/\\/g, '/')
71
+ .replace(/^(\.\/)+/, '')
72
+ .replace(/^\/+/, '');
73
+ }
74
+
75
+ /**
76
+ * Project-relative path of a file entry: a string, or an object with relativePath or path
77
+ * @param {string|{relativePath?: string, path?: string}} item
78
+ * @returns {string}
79
+ */
80
+ export function relativePathOf(item) {
81
+ if (typeof item === 'string') return normalizeRelativePath(item);
82
+ return normalizeRelativePath(item?.relativePath ?? item?.path ?? '');
83
+ }
84
+
85
+ /**
86
+ * Compile a model glob (`*` any characters, `?` one character), case-insensitive
87
+ * @param {string} glob
88
+ * @returns {RegExp}
89
+ */
90
+ export function modelGlobToRegex(glob) {
91
+ const source = glob
92
+ .split('')
93
+ .map((char) => {
94
+ if (char === '*') return '.*';
95
+ if (char === '?') return '.';
96
+ return char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
97
+ })
98
+ .join('');
99
+ return new RegExp(`^${source}$`, 'i');
100
+ }
101
+
102
+ const isPlainObject = (value) =>
103
+ value !== null && typeof value === 'object' && !Array.isArray(value);
104
+ const show = (value) => JSON.stringify(value);
105
+
106
+ /**
107
+ * Validate one parsed policy document and return its rules in normal form
108
+ * @param {*} doc - Parsed JSON
109
+ * @param {object} context
110
+ * @param {string} context.file - Policy file, for messages
111
+ * @param {'organisation'|'project'} context.scope - Who owns the file
112
+ * @param {string} context.root - Project root, for the audit path
113
+ * @returns {{deny: string[], requireRedaction: boolean, maxExportTokens: number|null,
114
+ * allowedTargetModels: string[]|null, ignoreFiles: string[],
115
+ * audit: {enabled: boolean, path: string|null}}}
116
+ * @throws {PolicyConfigError}
117
+ */
118
+ export function validatePolicyDocument(doc, { file, scope, root }) {
119
+ const errors = [];
120
+ const fail = (field, message) => errors.push({ field, message });
121
+
122
+ if (!isPlainObject(doc)) {
123
+ throw new PolicyConfigError(file, [
124
+ { field: null, message: `must contain a JSON object (got ${show(doc)})` },
125
+ ]);
126
+ }
127
+
128
+ // Keys starting with "$" ($comment, $schema) are free-form notes: JSON has no comments
129
+ for (const key of Object.keys(doc)) {
130
+ if (!key.startsWith('$') && !FIELDS.includes(key)) {
131
+ fail(key, `is not a policy field (known fields: ${FIELDS.join(', ')})`);
132
+ }
133
+ }
134
+
135
+ if (!('version' in doc)) {
136
+ fail('version', `is required (use ${POLICY_VERSION})`);
137
+ } else if (doc.version !== POLICY_VERSION) {
138
+ fail('version', `must be ${POLICY_VERSION} (got ${show(doc.version)})`);
139
+ }
140
+
141
+ const deny = validateStringList(doc.deny, 'deny', fail, { allowNull: false }) ?? [];
142
+ deny.forEach((pattern, index) => {
143
+ const body = pattern.replace(/^!/, '');
144
+ if (!body || body === '/') {
145
+ fail(`deny[${index}]`, `is not a usable pattern (got ${show(pattern)})`);
146
+ } else if (/(^|[/\\])\.\.([/\\]|$)/.test(body)) {
147
+ fail(`deny[${index}]`, `must not contain ".." (patterns are relative to the project root)`);
148
+ }
149
+ });
150
+
151
+ let requireRedaction = false;
152
+ if (doc.requireRedaction !== undefined) {
153
+ if (typeof doc.requireRedaction === 'boolean') requireRedaction = doc.requireRedaction;
154
+ else fail('requireRedaction', `must be true or false (got ${show(doc.requireRedaction)})`);
155
+ }
156
+
157
+ let maxExportTokens = null;
158
+ if (doc.maxExportTokens !== undefined && doc.maxExportTokens !== null) {
159
+ if (Number.isInteger(doc.maxExportTokens) && doc.maxExportTokens > 0) {
160
+ maxExportTokens = doc.maxExportTokens;
161
+ } else {
162
+ fail(
163
+ 'maxExportTokens',
164
+ `must be a positive integer or null (got ${show(doc.maxExportTokens)})`
165
+ );
166
+ }
167
+ }
168
+
169
+ const allowedTargetModels = validateStringList(
170
+ doc.allowedTargetModels,
171
+ 'allowedTargetModels',
172
+ fail,
173
+ { allowNull: true }
174
+ );
175
+ if (Array.isArray(allowedTargetModels) && allowedTargetModels.length === 0) {
176
+ fail('allowedTargetModels', 'must list at least one model pattern; omit it to allow any model');
177
+ }
178
+
179
+ const ignoreFiles = validateStringList(doc.ignoreFiles, 'ignoreFiles', fail, {
180
+ allowNull: false,
181
+ });
182
+ (ignoreFiles ?? []).forEach((name, index) => {
183
+ if (name !== 'all' && !isValidIgnoreFileName(name)) {
184
+ fail(
185
+ `ignoreFiles[${index}]`,
186
+ `must be a file name in the project root or "all" (got ${show(name)})`
187
+ );
188
+ }
189
+ });
190
+
191
+ const audit = validateAudit(doc.audit, { scope, root, fail });
192
+
193
+ if (errors.length > 0) throw new PolicyConfigError(file, errors);
194
+
195
+ return {
196
+ deny,
197
+ requireRedaction,
198
+ maxExportTokens,
199
+ allowedTargetModels: allowedTargetModels ?? null,
200
+ ignoreFiles: resolveIgnoreFileNames(ignoreFiles ?? []),
201
+ audit,
202
+ };
203
+ }
204
+
205
+ function validateStringList(value, field, fail, { allowNull }) {
206
+ if (value === undefined || (allowNull && value === null)) return null;
207
+ if (!Array.isArray(value)) {
208
+ fail(field, `must be an array of strings${allowNull ? ' or null' : ''} (got ${show(value)})`);
209
+ return null;
210
+ }
211
+ const valid = [];
212
+ value.forEach((item, index) => {
213
+ if (typeof item === 'string' && item.trim()) valid.push(item.trim());
214
+ else fail(`${field}[${index}]`, `must be a non-empty string (got ${show(item)})`);
215
+ });
216
+ return valid;
217
+ }
218
+
219
+ function validateAudit(audit, { scope, root, fail }) {
220
+ if (audit === undefined || audit === null) return { enabled: false, path: null };
221
+ if (!isPlainObject(audit)) {
222
+ fail('audit', `must be an object like {"enabled": true} (got ${show(audit)})`);
223
+ return { enabled: false, path: null };
224
+ }
225
+
226
+ for (const key of Object.keys(audit)) {
227
+ if (!AUDIT_FIELDS.includes(key)) {
228
+ fail(`audit.${key}`, `is not an audit field (known fields: ${AUDIT_FIELDS.join(', ')})`);
229
+ }
230
+ }
231
+
232
+ let enabled = true;
233
+ if (audit.enabled !== undefined) {
234
+ if (typeof audit.enabled === 'boolean') enabled = audit.enabled;
235
+ else fail('audit.enabled', `must be true or false (got ${show(audit.enabled)})`);
236
+ }
237
+
238
+ let logPath = DEFAULT_AUDIT_PATH;
239
+ if (audit.path !== undefined) {
240
+ if (typeof audit.path === 'string' && audit.path.trim()) logPath = audit.path.trim();
241
+ else fail('audit.path', `must be a non-empty string (got ${show(audit.path)})`);
242
+ }
243
+
244
+ // A project file travels with the repository, so it may only write inside it; the
245
+ // organisation file comes from the user's environment and may name any absolute path
246
+ if (path.isAbsolute(logPath)) {
247
+ if (scope !== 'organisation') {
248
+ fail('audit.path', 'must be relative to the project root in a project policy');
249
+ return { enabled: false, path: null };
250
+ }
251
+ return { enabled, path: path.resolve(logPath) };
252
+ }
253
+
254
+ const resolved = path.resolve(root, logPath);
255
+ const relative = path.relative(root, resolved);
256
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
257
+ fail('audit.path', `must stay inside the project root (got ${show(audit.path)})`);
258
+ return { enabled: false, path: null };
259
+ }
260
+ return { enabled, path: resolved };
261
+ }
262
+
263
+ /**
264
+ * Read and validate one policy file
265
+ * @returns {{scope: string, file: string, rules: object}|null} Null when the file is absent
266
+ */
267
+ function readPolicySource(file, scope, root, { required }) {
268
+ if (!fs.existsSync(file)) {
269
+ if (!required) return null;
270
+ throw new PolicyConfigError(file, [
271
+ { field: null, message: `was not found (named by ${POLICY_ENV})` },
272
+ ]);
273
+ }
274
+
275
+ let text;
276
+ try {
277
+ text = fs.readFileSync(file, 'utf8');
278
+ } catch (error) {
279
+ throw new PolicyConfigError(file, [
280
+ { field: null, message: `cannot be read: ${error.message}` },
281
+ ]);
282
+ }
283
+
284
+ let doc;
285
+ try {
286
+ doc = JSON.parse(text);
287
+ } catch (error) {
288
+ throw new PolicyConfigError(file, [
289
+ { field: null, message: `is not valid JSON: ${error.message}` },
290
+ ]);
291
+ }
292
+
293
+ return { scope, file, rules: validatePolicyDocument(doc, { file, scope, root }) };
294
+ }
295
+
296
+ /**
297
+ * Load the effective policy for a project: CTXMAN_POLICY (organisation) plus
298
+ * <root>/.ctxman/policy.json (project). Without either file the result is a permissive
299
+ * policy whose `active` is false.
300
+ * @param {string} [root=process.cwd()] - Project root
301
+ * @param {object} [options]
302
+ * @param {object} [options.env=process.env] - Environment to read CTXMAN_POLICY from
303
+ * @returns {Policy}
304
+ * @throws {PolicyConfigError} When a policy file is invalid, or CTXMAN_POLICY names a
305
+ * missing file
306
+ */
307
+ export function loadPolicy(root = process.cwd(), { env = process.env } = {}) {
308
+ const absRoot = path.resolve(root);
309
+ const sources = [];
310
+
311
+ const orgSetting = typeof env?.[POLICY_ENV] === 'string' ? env[POLICY_ENV].trim() : '';
312
+ const orgFile = orgSetting ? path.resolve(orgSetting) : null;
313
+ if (orgFile) {
314
+ sources.push(readPolicySource(orgFile, 'organisation', absRoot, { required: true }));
315
+ }
316
+
317
+ const projectFile = path.join(absRoot, POLICY_FILE);
318
+ if (projectFile !== orgFile) {
319
+ const project = readPolicySource(projectFile, 'project', absRoot, { required: false });
320
+ if (project) sources.push(project);
321
+ }
322
+
323
+ return new Policy({ root: absRoot, sources });
324
+ }
325
+
326
+ /**
327
+ * The effective (merged) policy of a project
328
+ */
329
+ export class Policy {
330
+ /**
331
+ * @param {object} params
332
+ * @param {string} params.root - Project root
333
+ * @param {Array<{scope: string, file: string, rules: object}>} [params.sources] - Validated
334
+ * files, organisation first
335
+ */
336
+ constructor({ root, sources = [] }) {
337
+ const parser = new GitIgnoreParser(null, null, null);
338
+ this.root = path.resolve(root);
339
+ this.sources = sources.map(({ scope, file }) => ({ scope, file }));
340
+ this.active = sources.length > 0;
341
+
342
+ this.denyRules = sources
343
+ .filter((source) => source.rules.deny.length > 0)
344
+ .map(({ scope, file, rules }) => ({
345
+ scope,
346
+ file,
347
+ patterns: rules.deny,
348
+ compiled: rules.deny.map((pattern) => parser.convertToRegex(pattern)),
349
+ }));
350
+
351
+ this.requireRedaction = sources.some((source) => source.rules.requireRedaction);
352
+ this.requireRedactionSources = sources
353
+ .filter((source) => source.rules.requireRedaction)
354
+ .map((source) => source.file);
355
+
356
+ const limits = sources.filter((source) => source.rules.maxExportTokens !== null);
357
+ const strictest = limits.reduce(
358
+ (best, source) =>
359
+ !best || source.rules.maxExportTokens < best.rules.maxExportTokens ? source : best,
360
+ null
361
+ );
362
+ this.maxExportTokens = strictest ? strictest.rules.maxExportTokens : null;
363
+ this.maxExportTokensSource = strictest ? strictest.file : null;
364
+
365
+ this.modelRules = sources
366
+ .filter((source) => source.rules.allowedTargetModels)
367
+ .map(({ scope, file, rules }) => ({
368
+ scope,
369
+ file,
370
+ patterns: rules.allowedTargetModels,
371
+ regexes: rules.allowedTargetModels.map(modelGlobToRegex),
372
+ }));
373
+
374
+ this.ignoreFiles = [...new Set(sources.flatMap((source) => source.rules.ignoreFiles))];
375
+
376
+ const auditPaths = sources
377
+ .filter((source) => source.rules.audit.enabled && source.rules.audit.path)
378
+ .map((source) => source.rules.audit.path);
379
+ this.audit = { enabled: auditPaths.length > 0, paths: [...new Set(auditPaths)] };
380
+ }
381
+
382
+ /**
383
+ * Whether a file may never be exported. Each policy file is evaluated on its own with
384
+ * .gitignore syntax (the last matching line wins, `!` re-allows, `dir/` covers everything
385
+ * below); the path is denied when any file denies it, so a project file cannot re-allow
386
+ * what the organisation file denies.
387
+ * @param {string} relativePath - Path relative to the project root
388
+ * @returns {boolean}
389
+ */
390
+ isDenied(relativePath) {
391
+ return this.deniedBy(relativePath) !== null;
392
+ }
393
+
394
+ /**
395
+ * @param {string} relativePath
396
+ * @returns {string|null} The policy file whose deny rules match, or null
397
+ */
398
+ deniedBy(relativePath) {
399
+ const rel = normalizeRelativePath(relativePath);
400
+ if (!rel) return null;
401
+
402
+ for (const rule of this.denyRules) {
403
+ let denied = false;
404
+ for (const pattern of rule.compiled) {
405
+ if (pattern.regex.test(rel)) denied = !pattern.isNegation;
406
+ }
407
+ if (denied) return rule.file;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ /**
413
+ * Split file entries into the ones that may be exported and the denied ones
414
+ * @template T
415
+ * @param {T[]} items - Paths or objects with relativePath/path
416
+ * @returns {{kept: T[], removed: T[]}}
417
+ */
418
+ filterFiles(items) {
419
+ const kept = [];
420
+ const removed = [];
421
+ for (const item of items) {
422
+ (this.isDenied(relativePathOf(item)) ? removed : kept).push(item);
423
+ }
424
+ return { kept, removed };
425
+ }
426
+
427
+ /**
428
+ * Whether exports may target this model; true when no file restricts models or no
429
+ * model was named
430
+ * @param {string|null|undefined} model
431
+ * @returns {boolean}
432
+ */
433
+ isModelAllowed(model) {
434
+ if (!model) return true;
435
+ return this.modelRules.every((rule) => rule.regexes.some((regex) => regex.test(model)));
436
+ }
437
+
438
+ /**
439
+ * Whether a project-relative path is one of ctxman's audit logs, which must never be
440
+ * exported themselves
441
+ * @param {string} relativePath
442
+ * @returns {boolean}
443
+ */
444
+ isAuditLog(relativePath) {
445
+ const rel = normalizeRelativePath(relativePath);
446
+ if (!rel) return false;
447
+ if (rel === DEFAULT_AUDIT_PATH) return true;
448
+ const absolute = path.resolve(this.root, rel);
449
+ return this.audit.paths.includes(absolute);
450
+ }
451
+
452
+ /**
453
+ * Plain-object view of the effective policy (for `ctxman policy check --json`)
454
+ * @returns {object}
455
+ */
456
+ toJSON() {
457
+ return {
458
+ active: this.active,
459
+ root: this.root,
460
+ sources: this.sources,
461
+ deny: this.denyRules.map(({ scope, file, patterns }) => ({ scope, file, patterns })),
462
+ requireRedaction: this.requireRedaction,
463
+ maxExportTokens: this.maxExportTokens,
464
+ allowedTargetModels:
465
+ this.modelRules.length > 0
466
+ ? this.modelRules.map(({ scope, file, patterns }) => ({ scope, file, patterns }))
467
+ : null,
468
+ ignoreFiles: this.ignoreFiles,
469
+ audit: this.audit,
470
+ };
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Starter policy written by `ctxman policy init`
476
+ * @returns {object}
477
+ */
478
+ export function createStarterPolicy() {
479
+ return {
480
+ $comment: `ctxman export policy. Files matching "deny" are never exported; set a field to null or remove it to lift that limit. Reference: ${POLICY_DOCS_URL}`,
481
+ version: POLICY_VERSION,
482
+ deny: ['.env', '.env.*', '!.env.example', '**/*.pem', '**/*.key', '**/id_rsa*', 'secrets/'],
483
+ requireRedaction: true,
484
+ maxExportTokens: null,
485
+ allowedTargetModels: null,
486
+ ignoreFiles: [],
487
+ audit: { enabled: true, path: DEFAULT_AUDIT_PATH },
488
+ };
489
+ }