@gitset-dev/cli 2.3.3 → 2.4.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.
package/index.js CHANGED
@@ -27,6 +27,7 @@ ${theme.bold('AI commands')} (use your own provider key):
27
27
  release Generate release notes from the commit range
28
28
  gitignore Generate a .gitignore for the detected stack
29
29
  repo about AI repo description + topics, applied via gh
30
+ knowledge Build an agent-ready knowledge base from your source code
30
31
 
31
32
  ${theme.bold('Local tools')} (no AI):
32
33
  repo license Generate a LICENSE file (offline)
@@ -121,6 +122,22 @@ Manage & apply a label pack (local pack + gh).
121
122
  without asking (--sync is an alias)`,
122
123
  dependabot: `Usage: gitset dependabot [--resolve] [--dry-run] [--save-dev]
123
124
  Resolve Dependabot alerts for the current repo (local + gh).`,
125
+ knowledge: `Usage: gitset knowledge <init|scan|generate|update|automate> [flags]
126
+ Build and maintain docs/gitset-knowledge/ — an agent-ready knowledge base
127
+ derived from your source code, manifests and CI config (never from existing
128
+ prose docs, so it can't inherit their drift). Secrets are redacted locally
129
+ before anything reaches your AI provider.
130
+ init scaffold .gitset-knowledge.json (optional scan config)
131
+ scan structural pass: zero AI calls, prints plan + cost estimate
132
+ generate full run (always shows the estimate and asks first)
133
+ update incremental: re-summarizes only changed modules
134
+ automate write a GitHub Actions workflow that runs update in CI
135
+ and opens a PR when docs change (always asks first)
136
+ --since <ref> update selector via git diff instead of content hashes
137
+ --mode <m> automate trigger: push | releases | weekly
138
+ --provider <p> override the default provider for this run
139
+ --model <m> override the model
140
+ --yes skip confirmations (CI)`,
124
141
  status: `Usage: gitset status
125
142
  Show git + configured-provider status.`,
126
143
  template: `Usage: gitset template <list|show|edit|path>
@@ -213,6 +230,8 @@ async function main() {
213
230
  code = await require('./src/commands/license').runLicenseCommand(rest); break;
214
231
  case 'labelspack':
215
232
  code = await require('./src/commands/labelspack').runLabelspackCommand(rest); break;
233
+ case 'knowledge':
234
+ code = await require('./src/commands/knowledge').runKnowledgeCommand(rest); break;
216
235
  case 'dependabot':
217
236
  code = (await require('./src/commands/dependabot-resolver')(null, rest)) || 0; break;
218
237
  case 'feedback':
@@ -53,6 +53,7 @@ async function generate(opts) {
53
53
  provider: cfg.provider,
54
54
  model: result.model,
55
55
  usage: result.usage,
56
+ finishReason: result.finishReason,
56
57
  };
57
58
  }
58
59
 
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const DEFAULTS = {
4
+ maxFileChars: 6000,
5
+ signatureChars: 3000,
6
+ maxBatchChars: 24000,
7
+ writeInputChars: 40000,
8
+ summarizeMaxTokens: 2048,
9
+ writeMaxTokens: 6144,
10
+ };
11
+
12
+ const DECLARATION_RE = /^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|def|interface|type|enum|struct|impl|fn|func|module\.exports|exports\.)[^\n]*$/gm;
13
+
14
+ function estimateTokens(chars) {
15
+ return Math.ceil(chars / 4);
16
+ }
17
+
18
+ function extractSignatures(content, limit) {
19
+ const head = content.slice(0, Math.floor(limit / 3));
20
+ const declarations = [];
21
+ DECLARATION_RE.lastIndex = 0;
22
+ let m;
23
+ while ((m = DECLARATION_RE.exec(content)) !== null) {
24
+ const line = m[0].trim();
25
+ if (line.length > 3) declarations.push(line.slice(0, 200));
26
+ if (declarations.length >= 80) break;
27
+ }
28
+ const sigBlock = declarations.join('\n');
29
+ const combined = `${head}\n\n[declarations extracted from the rest of the file]\n${sigBlock}`;
30
+ return combined.length > limit ? `${combined.slice(0, limit)}\n…(truncated)` : combined;
31
+ }
32
+
33
+ function budgetFileContent(content, opts = {}) {
34
+ const { maxFileChars, signatureChars } = { ...DEFAULTS, ...opts };
35
+ if (content.length <= maxFileChars) {
36
+ return { content, mode: 'full' };
37
+ }
38
+ return { content: extractSignatures(content, signatureChars), mode: 'signatures' };
39
+ }
40
+
41
+ function batchModules(preparedModules, opts = {}) {
42
+ const { maxBatchChars } = { ...DEFAULTS, ...opts };
43
+ const batches = [];
44
+ for (const mod of preparedModules) {
45
+ let current = { module: mod.name, part: 1, files: [], chars: 0 };
46
+ for (const file of mod.files) {
47
+ const fileChars = file.content ? file.content.length : 0;
48
+ if (current.files.length > 0 && current.chars + fileChars > maxBatchChars) {
49
+ batches.push(current);
50
+ current = { module: mod.name, part: current.part + 1, files: [], chars: 0 };
51
+ }
52
+ current.files.push(file);
53
+ current.chars += fileChars;
54
+ }
55
+ if (current.files.length > 0) batches.push(current);
56
+ }
57
+ return batches;
58
+ }
59
+
60
+ function estimateRun({ batches, docCount, systemOverheadChars = 1500 }, opts = {}) {
61
+ const { summarizeMaxTokens, writeMaxTokens, writeInputChars } = { ...DEFAULTS, ...opts };
62
+ const summarizeCalls = batches.length;
63
+ const summarizeInputTokens = batches.reduce(
64
+ (sum, b) => sum + estimateTokens(b.chars + systemOverheadChars),
65
+ 0,
66
+ );
67
+ const writeCalls = docCount;
68
+ const writeInputTokens = writeCalls * estimateTokens(writeInputChars + systemOverheadChars);
69
+ return {
70
+ totalCalls: summarizeCalls + writeCalls,
71
+ summarizeCalls,
72
+ writeCalls,
73
+ estInputTokens: summarizeInputTokens + writeInputTokens,
74
+ estMaxOutputTokens: summarizeCalls * summarizeMaxTokens + writeCalls * writeMaxTokens,
75
+ };
76
+ }
77
+
78
+ module.exports = {
79
+ DEFAULTS,
80
+ budgetFileContent,
81
+ extractSignatures,
82
+ batchModules,
83
+ estimateRun,
84
+ estimateTokens,
85
+ };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const WORKFLOW_PATH = '.github/workflows/gitset-knowledge.yml';
4
+
5
+ const MODES = {
6
+ push: 'on every push to the default branch (paths-filtered; unchanged code costs nothing)',
7
+ releases: 'on every published release',
8
+ weekly: 'weekly (Mondays 06:00 UTC)',
9
+ };
10
+
11
+ function buildTrigger(mode, defaultBranch) {
12
+ if (mode === 'push') {
13
+ return [
14
+ 'on:',
15
+ ' workflow_dispatch:',
16
+ ' push:',
17
+ ` branches: [${defaultBranch}]`,
18
+ ' paths-ignore:',
19
+ " - 'docs/**'",
20
+ " - '**.md'",
21
+ " - '.gitignore'",
22
+ " - 'LICENSE'",
23
+ ];
24
+ }
25
+ if (mode === 'releases') {
26
+ return [
27
+ 'on:',
28
+ ' workflow_dispatch:',
29
+ ' release:',
30
+ ' types: [published]',
31
+ ];
32
+ }
33
+ if (mode === 'weekly') {
34
+ return [
35
+ 'on:',
36
+ ' workflow_dispatch:',
37
+ ' schedule:',
38
+ " - cron: '0 6 * * 1'",
39
+ ];
40
+ }
41
+ throw new Error(`Unknown automation mode "${mode}". One of: ${Object.keys(MODES).join(', ')}`);
42
+ }
43
+
44
+ function buildKnowledgeWorkflow({ mode, provider, envKey, model, defaultBranch = 'main' }) {
45
+ if (!provider || !envKey) throw new Error('provider and envKey are required');
46
+ const updateCmd = `gitset knowledge update --yes --provider ${provider}${model ? ` --model ${model}` : ''}`;
47
+
48
+ return [
49
+ 'name: Gitset Knowledge Mapper',
50
+ `# Incrementally refreshes docs/gitset-knowledge/ with your own AI key.`,
51
+ `# Requires ONE repository secret: ${envKey} (Settings > Secrets > Actions).`,
52
+ '# The key is stored encrypted by GitHub and is only sent to your AI',
53
+ '# provider — never to Gitset. Runs whose mapped source files are',
54
+ '# unchanged exit without any AI call (content-hash diff), so harmless',
55
+ '# triggers cost nothing beyond a few CI seconds.',
56
+ '#',
57
+ '# Also requires "Allow GitHub Actions to create and approve pull',
58
+ '# requests" to be enabled: Settings > Actions > General > Workflow',
59
+ '# permissions. Without it, the update still commits and pushes',
60
+ '# safely, but opening the review PR fails and this job reports it',
61
+ '# as a failure so it is never silently missed.',
62
+ ...buildTrigger(mode, defaultBranch),
63
+ 'permissions:',
64
+ ' contents: write',
65
+ ' pull-requests: write',
66
+ 'concurrency:',
67
+ ' group: gitset-knowledge',
68
+ ' cancel-in-progress: false',
69
+ 'jobs:',
70
+ ' update:',
71
+ ' runs-on: ubuntu-latest',
72
+ ' steps:',
73
+ ' - uses: actions/checkout@v4',
74
+ ' - uses: actions/setup-node@v4',
75
+ ' with:',
76
+ " node-version: '20'",
77
+ ' - run: npm install -g @gitset-dev/cli',
78
+ ' - name: Update knowledge base',
79
+ ' env:',
80
+ ` ${envKey}: \${{ secrets.${envKey} }}`,
81
+ ` run: ${updateCmd}`,
82
+ ' - name: Open a PR when the knowledge base changed',
83
+ ' env:',
84
+ ' GH_TOKEN: ${{ github.token }}',
85
+ ' run: |',
86
+ ' if git diff --quiet -- docs/gitset-knowledge AGENTS.md; then',
87
+ ' echo "Knowledge base already up to date."',
88
+ ' exit 0',
89
+ ' fi',
90
+ ' git config user.name "github-actions[bot]"',
91
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
92
+ ' git switch -c gitset/knowledge-update',
93
+ ' git add docs/gitset-knowledge AGENTS.md',
94
+ ' git commit -m "docs: refresh knowledge base"',
95
+ ' git push -f origin gitset/knowledge-update',
96
+ ' echo "Committed and pushed gitset/knowledge-update — this work is now safe on GitHub regardless of the next step."',
97
+ ' if PR_OUTPUT=$(gh pr create --title "docs: refresh knowledge base" --body "Automated incremental update by [Gitset](https://gitset.dev) Knowledge Mapper. Only changed modules were re-analyzed; review like any other docs change." 2>&1); then',
98
+ ' echo "$PR_OUTPUT"',
99
+ ' elif echo "$PR_OUTPUT" | grep -qi "already exists"; then',
100
+ ' echo "A pull request for gitset/knowledge-update already exists — nothing further to do."',
101
+ ' echo "$PR_OUTPUT"',
102
+ ' else',
103
+ ' echo "$PR_OUTPUT"',
104
+ ' echo "::error::Could not open the pull request automatically (the branch and commit ARE safely pushed — no work was lost). This is usually because \'Allow GitHub Actions to create and approve pull requests\' is disabled for this repository: enable it under Settings > Actions > General > Workflow permissions, then either re-run this workflow or open the PR yourself: https://github.com/${{ github.repository }}/pull/new/gitset/knowledge-update"',
105
+ ' exit 1',
106
+ ' fi',
107
+ '',
108
+ ].join('\n');
109
+ }
110
+
111
+ module.exports = {
112
+ WORKFLOW_PATH,
113
+ MODES,
114
+ buildKnowledgeWorkflow,
115
+ };
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ const SOURCE_EXTS = new Set([
8
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts',
9
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.h',
10
+ '.cpp', '.hpp', '.cc', '.cs', '.php', '.scala', '.ex', '.exs',
11
+ '.sh', '.bash', '.zsh', '.astro', '.vue', '.svelte',
12
+ ]);
13
+
14
+ const MANIFEST_NAMES = new Set([
15
+ 'package.json', 'pyproject.toml', 'cargo.toml', 'go.mod', 'gemfile',
16
+ 'requirements.txt', 'setup.py', 'setup.cfg', 'composer.json',
17
+ 'pom.xml', 'build.gradle', 'pnpm-workspace.yaml', 'deno.json',
18
+ ]);
19
+
20
+ const CONFIG_PATTERNS = [
21
+ /^\.?[\w-]+rc(\.\w+)?$/i,
22
+ /\.config\.(js|cjs|mjs|ts|json)$/i,
23
+ /^tsconfig(\..+)?\.json$/i,
24
+ /^dockerfile$/i,
25
+ /^docker-compose(\..+)?\.ya?ml$/i,
26
+ /^makefile$/i,
27
+ /^cloudbuild\.ya?ml$/i,
28
+ /^\.gitattributes$/i,
29
+ /^\.editorconfig$/i,
30
+ /^\.npmrc$/i,
31
+ ];
32
+
33
+ const DOC_EXTS = new Set(['.md', '.mdx', '.rst', '.txt', '.adoc']);
34
+ const DOC_NAMES = new Set(['license', 'notice', 'authors', 'codeowners', 'changelog']);
35
+
36
+ const LOCKFILE_NAMES = new Set([
37
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'cargo.lock',
38
+ 'poetry.lock', 'gemfile.lock', 'composer.lock', 'bun.lockb',
39
+ 'deno.lock', 'uv.lock', 'pipfile.lock', 'go.sum',
40
+ ]);
41
+
42
+ const ASSET_EXTS = new Set([
43
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.avif',
44
+ '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp4', '.webm', '.mp3',
45
+ '.wav', '.pdf', '.zip', '.gz', '.tar', '.br', '.wasm', '.map',
46
+ '.lock', '.lockb', '.min.js', '.min.css',
47
+ ]);
48
+
49
+ const TEST_PATTERNS = [
50
+ /(^|\/)__tests__\//,
51
+ /(^|\/)tests?\//,
52
+ /\.(test|spec)\.[jt]sx?$/,
53
+ /_test\.(go|py|rb)$/,
54
+ /(^|\/)conftest\.py$/,
55
+ ];
56
+
57
+ const SENSITIVE_FILE_PATTERNS = [
58
+ /(^|\/)\.env(\.|$)/i,
59
+ /\.pem$/i,
60
+ /\.key$/i,
61
+ /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.|$)/i,
62
+ /\.p12$/i,
63
+ /\.pfx$/i,
64
+ /\.keystore$/i,
65
+ /(^|\/)credentials?(\.|$)/i,
66
+ /(^|\/)secrets?\.(json|ya?ml|toml|txt)$/i,
67
+ /\.tfstate(\.|$)/i,
68
+ /(^|\/)\.netrc$/i,
69
+ /(^|\/)\.npmrc$/i,
70
+ ];
71
+
72
+ const ALWAYS_IGNORED_DIRS = new Set([
73
+ 'node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next',
74
+ '.astro', '.vercel', '.turbo', '.cache', 'vendor', '__pycache__',
75
+ '.venv', 'venv', 'target', '.idea', '.vscode', '.DS_Store',
76
+ ]);
77
+
78
+ const OUTPUT_DIR = 'docs/gitset-knowledge';
79
+ const MAX_CONTENT_BYTES = 200 * 1024;
80
+
81
+ function gitListFiles(rootDir) {
82
+ try {
83
+ const out = execFileSync(
84
+ 'git',
85
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard'],
86
+ { cwd: rootDir, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
87
+ );
88
+ return out.split('\0').filter(Boolean);
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function walkFiles(rootDir) {
95
+ const results = [];
96
+ const stack = [''];
97
+ while (stack.length) {
98
+ const rel = stack.pop();
99
+ const abs = path.join(rootDir, rel);
100
+ let entries;
101
+ try {
102
+ entries = fs.readdirSync(abs, { withFileTypes: true });
103
+ } catch {
104
+ continue;
105
+ }
106
+ for (const entry of entries) {
107
+ if (entry.isSymbolicLink()) continue;
108
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
109
+ if (entry.isDirectory()) {
110
+ if (!ALWAYS_IGNORED_DIRS.has(entry.name)) stack.push(childRel);
111
+ } else if (entry.isFile()) {
112
+ results.push(childRel);
113
+ }
114
+ }
115
+ }
116
+ return results;
117
+ }
118
+
119
+ function isSensitivePath(relPath) {
120
+ const p = relPath.toLowerCase();
121
+ return SENSITIVE_FILE_PATTERNS.some((re) => re.test(p));
122
+ }
123
+
124
+ function classify(relPath) {
125
+ const base = path.basename(relPath).toLowerCase();
126
+ const ext = path.extname(base);
127
+ const stem = base.replace(/\.[^.]*$/, '');
128
+
129
+ if (isSensitivePath(relPath)) return 'sensitive';
130
+ if (LOCKFILE_NAMES.has(base)) return 'asset';
131
+ if (MANIFEST_NAMES.has(base)) return 'manifest';
132
+ if (base.endsWith('.gemspec')) return 'manifest';
133
+ if (TEST_PATTERNS.some((re) => re.test(relPath))) return 'test';
134
+ if (/^\.github\/workflows\/.+\.ya?ml$/.test(relPath)) return 'config';
135
+ if (CONFIG_PATTERNS.some((re) => re.test(base))) return 'config';
136
+ if (DOC_EXTS.has(ext) || ((DOC_NAMES.has(stem) || DOC_NAMES.has(base)) && !SOURCE_EXTS.has(ext))) return 'doc';
137
+ if (relPath.startsWith('docs/')) return 'doc';
138
+ if (ASSET_EXTS.has(ext) || base.endsWith('.min.js') || base.endsWith('.min.css')) return 'asset';
139
+ if (SOURCE_EXTS.has(ext)) return 'source';
140
+ if (ext === '.json' || ext === '.yaml' || ext === '.yml' || ext === '.toml') return 'config';
141
+ return 'other';
142
+ }
143
+
144
+ function looksBinary(buffer) {
145
+ const slice = buffer.subarray(0, Math.min(buffer.length, 8192));
146
+ return slice.includes(0);
147
+ }
148
+
149
+ function discover(rootDir, options = {}) {
150
+ const { include = [], exclude = [] } = options;
151
+ let paths = gitListFiles(rootDir);
152
+ const viaGit = paths !== null;
153
+ if (!paths) paths = walkFiles(rootDir);
154
+
155
+ const excludeRes = exclude.map(globToRegExp);
156
+ const includeRes = include.map(globToRegExp);
157
+
158
+ const files = [];
159
+ for (const relPath of paths.sort()) {
160
+ const norm = relPath.split(path.sep).join('/');
161
+ if (norm.startsWith(`${OUTPUT_DIR}/`)) continue;
162
+ if (norm.split('/').some((seg) => ALWAYS_IGNORED_DIRS.has(seg))) continue;
163
+ if (excludeRes.some((re) => re.test(norm))) continue;
164
+ if (includeRes.length && !includeRes.some((re) => re.test(norm))) continue;
165
+
166
+ let size = 0;
167
+ try {
168
+ size = fs.statSync(path.join(rootDir, norm)).size;
169
+ } catch {
170
+ continue;
171
+ }
172
+
173
+ files.push({ path: norm, kind: classify(norm), size });
174
+ }
175
+
176
+ return { files, viaGit };
177
+ }
178
+
179
+ function readContent(rootDir, file) {
180
+ if (file.kind === 'sensitive' || file.kind === 'asset') return null;
181
+ if (file.size > MAX_CONTENT_BYTES) return null;
182
+ let buffer;
183
+ try {
184
+ buffer = fs.readFileSync(path.join(rootDir, file.path));
185
+ } catch {
186
+ return null;
187
+ }
188
+ if (looksBinary(buffer)) return null;
189
+ return buffer.toString('utf8');
190
+ }
191
+
192
+ function globToRegExp(glob) {
193
+ const pattern = String(glob)
194
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
195
+ .replace(/\*\*\/|\*\*|\*|\?/g, (tok) => {
196
+ if (tok === '**/') return '(?:.*/)?';
197
+ if (tok === '**') return '.*';
198
+ if (tok === '*') return '[^/]*';
199
+ return '[^/]';
200
+ });
201
+ return new RegExp(`^${pattern}$`);
202
+ }
203
+
204
+ module.exports = {
205
+ discover,
206
+ classify,
207
+ readContent,
208
+ isSensitivePath,
209
+ globToRegExp,
210
+ OUTPUT_DIR,
211
+ MAX_CONTENT_BYTES,
212
+ };
@@ -0,0 +1,89 @@
1
+ // VENDORED from gitset-core-v2/lib/knowledge — do not edit here. Run `pnpm sync:ai`.
2
+ 'use strict';
3
+
4
+ const discoverLib = require('./discover');
5
+ const secretsLib = require('./secrets');
6
+ const mapLib = require('./map');
7
+ const budgetLib = require('./budget');
8
+ const stateLib = require('./state');
9
+ const writeLib = require('./write');
10
+ const validateLib = require('./validate');
11
+ const ciLib = require('./ci');
12
+
13
+ const LLM_ELIGIBLE_KINDS = new Set(['source', 'manifest', 'config']);
14
+
15
+ function prepareRun(rootDir, options = {}) {
16
+ const { include = [], exclude = [], budgets = {} } = options;
17
+
18
+ const { files, viaGit } = discoverLib.discover(rootDir, { include, exclude });
19
+
20
+ const contentCache = new Map();
21
+ const readCached = (file) => {
22
+ if (contentCache.has(file.path)) return contentCache.get(file.path);
23
+ const content = discoverLib.readContent(rootDir, file);
24
+ contentCache.set(file.path, content);
25
+ return content;
26
+ };
27
+
28
+ const map = mapLib.buildMap(files, readCached);
29
+
30
+ const redactionReport = { totalFindings: 0, droppedFiles: [], findingsByRule: {} };
31
+ const preparedByModule = new Map();
32
+
33
+ for (const file of files) {
34
+ if (!LLM_ELIGIBLE_KINDS.has(file.kind)) continue;
35
+ const raw = readCached(file);
36
+ if (raw === null) continue;
37
+
38
+ file.hash = stateLib.hashContent(raw);
39
+
40
+ const sanitized = secretsLib.sanitizeForPrompt(raw);
41
+ for (const rule of sanitized.findings) {
42
+ redactionReport.totalFindings += 1;
43
+ redactionReport.findingsByRule[rule] = (redactionReport.findingsByRule[rule] || 0) + 1;
44
+ }
45
+ if (sanitized.dropped) {
46
+ redactionReport.droppedFiles.push(file.path);
47
+ continue;
48
+ }
49
+
50
+ const budgeted = budgetLib.budgetFileContent(sanitized.content, budgets);
51
+ const moduleKey = mapLib.moduleKeyFor(file.path);
52
+ if (!preparedByModule.has(moduleKey)) preparedByModule.set(moduleKey, []);
53
+ preparedByModule.get(moduleKey).push({
54
+ path: file.path,
55
+ kind: file.kind,
56
+ mode: budgeted.mode,
57
+ centrality: map.centrality.get(file.path) || 0,
58
+ content: budgeted.content,
59
+ });
60
+ }
61
+
62
+ const preparedModules = [...preparedByModule.entries()]
63
+ .sort((a, b) => a[0].localeCompare(b[0]))
64
+ .map(([name, prepared]) => ({
65
+ name,
66
+ files: prepared.sort((a, b) => b.centrality - a.centrality || a.path.localeCompare(b.path)),
67
+ }));
68
+
69
+ const batches = budgetLib.batchModules(preparedModules, budgets);
70
+ const estimate = budgetLib.estimateRun(
71
+ { batches, docCount: writeLib.DOC_SPECS.length },
72
+ budgets,
73
+ );
74
+
75
+ return { files, map, preparedModules, batches, estimate, redactionReport, viaGit };
76
+ }
77
+
78
+ module.exports = {
79
+ prepareRun,
80
+ LLM_ELIGIBLE_KINDS,
81
+ ...discoverLib,
82
+ ...secretsLib,
83
+ ...mapLib,
84
+ ...budgetLib,
85
+ ...stateLib,
86
+ ...writeLib,
87
+ ...validateLib,
88
+ ...ciLib,
89
+ };