dual-brain 4.6.0 → 4.7.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.
@@ -9,20 +9,34 @@
9
9
  * Usage as CLI:
10
10
  * node .claude/hooks/gpt-work-dispatcher.mjs \
11
11
  * --task "Add tests for budget-balancer.mjs" \
12
- * --model gpt-5.4 \
12
+ * --tier execute \
13
13
  * --files hooks/budget-balancer.mjs
14
14
  *
15
15
  * Usage as module:
16
16
  * import { dispatchGptTask } from './gpt-work-dispatcher.mjs';
17
- * const result = await dispatchGptTask({ task, model, files, constraints, timeoutMs });
17
+ * const result = await dispatchGptTask({ task, model, tier, forceModel, files, constraints, timeoutMs });
18
18
  */
19
19
 
20
- import { execSync, spawnSync } from 'child_process';
20
+ import { spawnSync } from 'child_process';
21
21
  import { appendFileSync, readFileSync } from 'fs';
22
22
  import { dirname, join } from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
 
25
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
27
+ const EXECUTE_WORDS = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i;
28
+ const SEARCH_WORDS = /\b(explore|search|find|grep|locate|list\s+files|read[-\s]?only|lookup|scan)\b/i;
29
+ const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
30
+ const GPT_TIER_SANDBOX = {
31
+ search: 'read-only',
32
+ execute: 'danger-full-access',
33
+ think: 'read-only',
34
+ };
35
+ const GPT_TIER_PROMPTS = {
36
+ search: 'You are a READ-ONLY search agent. Do NOT edit files.',
37
+ execute: 'You are an execution agent. Edit files directly.',
38
+ think: 'You are an architecture/review agent. Analyze and recommend, do not edit unless explicitly asked.',
39
+ };
26
40
 
27
41
  // ---------------------------------------------------------------------------
28
42
  // Codex discovery — mirrors dual-brain-review.mjs
@@ -51,16 +65,66 @@ function findCodex() {
51
65
  return null;
52
66
  }
53
67
 
68
+ function isCodexAuthenticated(result) {
69
+ const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
70
+ if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
71
+ return result?.status === 0 ||
72
+ /\b(logged\s+in|authenticated|signed\s+in)\b/.test(out);
73
+ }
74
+
54
75
  // ---------------------------------------------------------------------------
55
76
  // Prompt builder
56
77
  // ---------------------------------------------------------------------------
57
78
 
79
+ function normalizeTier(tier) {
80
+ return ['search', 'execute', 'think'].includes(tier) ? tier : null;
81
+ }
82
+
83
+ function loadOrchestratorConfig() {
84
+ try {
85
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ export function classifyGptTier(task) {
92
+ const text = [
93
+ task?.task,
94
+ ...(Array.isArray(task?.constraints) ? task.constraints : []),
95
+ ]
96
+ .filter(Boolean)
97
+ .join(' ');
98
+
99
+ if (THINK_WORDS.test(text)) return 'think';
100
+ if (EXECUTE_WORDS.test(text)) return 'execute';
101
+ if (SEARCH_WORDS.test(text)) return 'search';
102
+ return 'execute';
103
+ }
104
+
105
+ export function resolveGptModel(tier, config = loadOrchestratorConfig()) {
106
+ const normalizedTier = normalizeTier(tier);
107
+ if (!normalizedTier) return null;
108
+
109
+ const models = config?.subscriptions?.openai?.models ?? {};
110
+ for (const [model, meta] of Object.entries(models)) {
111
+ if (meta?.tier === normalizedTier) return model;
112
+ }
113
+
114
+ if (normalizedTier === 'think') return 'gpt-5.5';
115
+ if (normalizedTier === 'search') return 'gpt-4.1-mini';
116
+ return 'gpt-5.4';
117
+ }
118
+
58
119
  function buildPrompt(task) {
120
+ const tierInstruction = GPT_TIER_PROMPTS[task.tier] || GPT_TIER_PROMPTS.execute;
59
121
  let prompt = `You are a GPT execution agent inside the Dual-Brain Orchestrator.
60
122
 
61
123
  Task: ${task.task}
62
124
 
63
- Own this task completely. Edit files directly.
125
+ ${tierInstruction}
126
+
127
+ Own this task completely.
64
128
 
65
129
  `;
66
130
  if (task.files?.length) {
@@ -81,13 +145,13 @@ Own this task completely. Edit files directly.
81
145
  // Codex executor
82
146
  // ---------------------------------------------------------------------------
83
147
 
84
- function executeCodex(codexBin, model, prompt, cwd, timeoutMs) {
148
+ function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access') {
85
149
  const startTime = Date.now();
86
150
 
87
151
  const proc = spawnSync(codexBin, [
88
152
  'exec', '--json', '--ephemeral',
89
153
  '-m', model,
90
- '-s', 'danger-full-access',
154
+ '-s', sandbox,
91
155
  prompt,
92
156
  ], {
93
157
  encoding: 'utf8',
@@ -154,12 +218,14 @@ const SESSION_ID = process.env.CLAUDE_SESSION_ID || process.ppid?.toString() ||
154
218
  function logUsageEvent(result, task) {
155
219
  const logFile = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
156
220
  const entryObj = {
157
- schema_version: 3,
221
+ schema_version: 4,
158
222
  timestamp: new Date().toISOString(),
159
223
  provider: 'openai',
160
224
  tier: task.tier || 'execute',
225
+ classified_tier: task.classifiedTier || task.tier || 'execute',
161
226
  tool: 'codex-exec',
162
227
  model: result.model,
228
+ model_override: task.modelOverride || null,
163
229
  status: result.success ? 'ok' : 'error',
164
230
  durationMs: result.durationMs,
165
231
  codex_startup_ms: result.startupMs || null,
@@ -202,6 +268,18 @@ function logUsageEvent(result, task) {
202
268
  // Main exported function
203
269
  // ---------------------------------------------------------------------------
204
270
 
271
+ function tryHealCodexAuth(codexBin) {
272
+ const apiKey = process.env.OPENAI_API_KEY;
273
+ if (!apiKey) return false;
274
+ const pipe = spawnSync(codexBin, ['login', '--with-api-key'], {
275
+ input: apiKey,
276
+ encoding: 'utf8',
277
+ stdio: ['pipe', 'pipe', 'pipe'],
278
+ timeout: 10000,
279
+ });
280
+ return pipe.status === 0;
281
+ }
282
+
205
283
  export async function dispatchGptTask(task) {
206
284
  const codexBin = findCodex();
207
285
  if (!codexBin) {
@@ -211,11 +289,70 @@ export async function dispatchGptTask(task) {
211
289
  };
212
290
  }
213
291
 
214
- const model = task.model || 'gpt-5.4';
215
- const prompt = buildPrompt(task);
216
- const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs);
292
+ // Pre-flight: check auth and heal if possible
293
+ const loginCheck = spawnSync(codexBin, ['login', 'status'], {
294
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000,
295
+ });
296
+ const isAuthed = isCodexAuthenticated(loginCheck);
297
+ if (!isAuthed) {
298
+ const healed = tryHealCodexAuth(codexBin);
299
+ if (!healed) {
300
+ return {
301
+ success: false,
302
+ error: 'Codex not authenticated. Run: npx dual-brain (sign in with your ChatGPT subscription) or codex login --device-auth',
303
+ };
304
+ }
305
+ }
306
+
307
+ const config = loadOrchestratorConfig();
308
+ const classifiedTier = classifyGptTier(task);
309
+ const explicitTier = normalizeTier(task.tier);
310
+ const tier = explicitTier || classifiedTier;
311
+ const expectedModel = resolveGptModel(tier, config) || 'gpt-5.4';
312
+
313
+ let model = task.model || expectedModel;
314
+ let modelOverride = null;
315
+
316
+ if (task.model && !task.forceModel && task.model !== expectedModel) {
317
+ console.warn(`[gpt-work-dispatcher] Warning: task classified as "${tier}", overriding requested model "${task.model}" with "${expectedModel}". Use --force-model to bypass.`);
318
+ model = expectedModel;
319
+ modelOverride = {
320
+ requested: task.model,
321
+ effective: expectedModel,
322
+ forced: false,
323
+ reason: `tier:${tier}`,
324
+ };
325
+ } else if (!task.model) {
326
+ modelOverride = {
327
+ requested: null,
328
+ effective: expectedModel,
329
+ forced: false,
330
+ reason: `auto-select:${tier}`,
331
+ };
332
+ } else if (task.forceModel) {
333
+ modelOverride = {
334
+ requested: task.model,
335
+ effective: task.model,
336
+ forced: true,
337
+ reason: `force-model:${tier}`,
338
+ };
339
+ }
340
+
341
+ const preparedTask = {
342
+ ...task,
343
+ tier,
344
+ classifiedTier,
345
+ modelOverride,
346
+ };
347
+ const prompt = buildPrompt(preparedTask);
348
+ const sandbox = GPT_TIER_SANDBOX[tier] || GPT_TIER_SANDBOX.execute;
349
+ const result = executeCodex(codexBin, model, prompt, task.cwd, task.timeoutMs, sandbox);
350
+ result.tier = tier;
351
+ result.classifiedTier = classifiedTier;
352
+ result.modelOverride = modelOverride;
353
+ result.sandbox = sandbox;
217
354
  result.profile = loadActiveProfile();
218
- logUsageEvent(result, task);
355
+ logUsageEvent(result, preparedTask);
219
356
  return result;
220
357
  }
221
358
 
@@ -261,6 +398,10 @@ function parseArgs(argv) {
261
398
  args.timeoutMs = Number(args.timeout) * 1000;
262
399
  delete args.timeout;
263
400
  }
401
+ if (typeof args['force-model'] === 'boolean') {
402
+ args.forceModel = args['force-model'];
403
+ delete args['force-model'];
404
+ }
264
405
 
265
406
  return args;
266
407
  }
@@ -273,7 +414,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
273
414
  const rawArgs = parseArgs(process.argv.slice(2));
274
415
 
275
416
  if (!rawArgs.task) {
276
- console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--model gpt-5.4] [--files file1,file2] [--timeout 120]');
417
+ console.error('Usage: node gpt-work-dispatcher.mjs --task "<description>" [--tier think|execute|search] [--model MODEL] [--force-model] [--files file1,file2] [--timeout 120]');
277
418
  process.exit(1);
278
419
  }
279
420
 
@@ -11,7 +11,7 @@
11
11
  * Checks:
12
12
  * 1. orchestrator.json — exists and parses as valid JSON
13
13
  * 2. pricing_verified — exists, warn if >30 days, fail if >90 days
14
- * 3. model_intelligence — inline in subscriptions, covers all models
14
+ * 3. model_intelligence — exists and covers all subscription models
15
15
  * 4. hook scripts — enforce-tier, cost-logger, quality-gate, dual-brain-review readable
16
16
  * 5. usage.jsonl active — recent entries (last 15 min) indicate PostToolUse hook is wired
17
17
  * 6. codex CLI — found on PATH or known locations; auth status checked
@@ -42,6 +42,13 @@ function check(name, status, detail) {
42
42
  return { name, status, detail };
43
43
  }
44
44
 
45
+ function isCodexAuthenticated(result) {
46
+ const output = ((result?.stdout || "") + (result?.stderr || "")).toLowerCase();
47
+ if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(output)) return false;
48
+ return result?.status === 0 ||
49
+ /\b(logged\s+in|authenticated|signed\s+in)\b/.test(output);
50
+ }
51
+
45
52
  // ---------------------------------------------------------------------------
46
53
  // Check implementations
47
54
  // ---------------------------------------------------------------------------
@@ -94,7 +101,7 @@ function checkPricingVerified() {
94
101
  return check("pricing_verified", STATUS.pass, `${ageDays} days ago`);
95
102
  }
96
103
 
97
- /** 3. model_intelligence — merged into subscriptions; validate inline fields */
104
+ /** 3. model_intelligence — exists and has entries for at least the subscription models */
98
105
  function checkModelIntelligence() {
99
106
  let config;
100
107
  try {
@@ -103,30 +110,31 @@ function checkModelIntelligence() {
103
110
  return check("model_intelligence", STATUS.fail, "cannot read config");
104
111
  }
105
112
 
106
- // Collect all models from subscriptions and check for intelligence fields
107
- let entryCount = 0;
108
- const missing = [];
109
- for (const [providerName, provider] of Object.entries(config.subscriptions || {})) {
110
- for (const [modelName, meta] of Object.entries(provider.models || {})) {
111
- entryCount++;
112
- if (!meta.best_for && !meta.model_id) {
113
- missing.push(modelName);
114
- }
115
- }
113
+ const mi = config.model_intelligence;
114
+ if (!mi || typeof mi !== "object") {
115
+ return check("model_intelligence", STATUS.fail, "key missing from config");
116
116
  }
117
117
 
118
- if (entryCount === 0) {
119
- return check("model_intelligence", STATUS.fail, "no models in subscriptions");
118
+ // Collect model keys from subscriptions
119
+ const subscriptionModels = new Set();
120
+ for (const provider of Object.values(config.subscriptions || {})) {
121
+ for (const key of Object.keys(provider.models || {})) {
122
+ subscriptionModels.add(key);
123
+ }
120
124
  }
121
125
 
126
+ const miKeys = Object.keys(mi);
127
+ const missing = [...subscriptionModels].filter((m) => !mi[m]);
128
+ const entryCount = miKeys.length;
129
+
122
130
  if (missing.length > 0) {
123
131
  return check(
124
132
  "model_intelligence",
125
133
  STATUS.warn,
126
- `${entryCount} models, missing intelligence: ${missing.join(", ")}`
134
+ `${entryCount} models, missing: ${missing.join(", ")}`
127
135
  );
128
136
  }
129
- return check("model_intelligence", STATUS.pass, `${entryCount} models (inline)`);
137
+ return check("model_intelligence", STATUS.pass, `${entryCount} models`);
130
138
  }
131
139
 
132
140
  /** 4. Hook scripts readable */
@@ -255,7 +263,7 @@ function checkCodexCli() {
255
263
 
256
264
  const output = (loginResult.stdout + loginResult.stderr).toLowerCase();
257
265
 
258
- if (loginResult.status === 0 || output.includes("logged in") || output.includes("authenticated")) {
266
+ if (isCodexAuthenticated(loginResult)) {
259
267
  return check("codex CLI", STATUS.pass, "authenticated");
260
268
  }
261
269
 
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { createHash } from 'crypto';
19
- import { execSync, spawnSync } from 'child_process';
19
+ import { spawnSync } from 'child_process';
20
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
21
21
  import { dirname, extname, join, resolve } from 'path';
22
22
  import { fileURLToPath } from 'url';
@@ -81,9 +81,14 @@ function exit(obj) {
81
81
  process.exit(0);
82
82
  }
83
83
 
84
- function runGit(cmd) {
84
+ function runGit(args) {
85
85
  try {
86
- return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
86
+ const proc = spawnSync('git', args, {
87
+ encoding: 'utf8',
88
+ stdio: ['pipe', 'pipe', 'pipe'],
89
+ timeout: 10_000,
90
+ });
91
+ return proc.status === 0 ? proc.stdout : '';
87
92
  } catch {
88
93
  return '';
89
94
  }
@@ -165,8 +170,8 @@ function matchesSkipPattern(filePath, patterns) {
165
170
  }
166
171
 
167
172
  function getChangedFiles() {
168
- const tracked = runGit('git diff --name-only HEAD') || '';
169
- const untracked = runGit('git ls-files --others --exclude-standard') || '';
173
+ const tracked = runGit(['diff', '--name-only', 'HEAD']) || '';
174
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard']) || '';
170
175
  const all = [...new Set([
171
176
  ...tracked.split('\n').filter(Boolean),
172
177
  ...untracked.split('\n').filter(Boolean),
@@ -252,7 +257,7 @@ function main() {
252
257
  }
253
258
 
254
259
  // Compute diff hash
255
- const diff = runGit('git diff HEAD');
260
+ const diff = runGit(['diff', 'HEAD']);
256
261
  const diffHash = createHash('sha256').update(diff).digest('hex').slice(0, 8);
257
262
 
258
263
  // Build review record (includes sensitivity info)
@@ -2,22 +2,9 @@
2
2
  /**
3
3
  * risk-classifier.mjs — File-path risk classification for adaptive routing.
4
4
  *
5
- * Exports:
6
- * classifyRisk(paths) → { level, reason } (static, backward-compat)
7
- * classifyRiskEnhanced(filePath) → { risk, basis, details } (empirical, v4.3.0+)
8
- * getGitChurn(filePath, days?) → { commits, isHot } | null
9
- * getFileRiskHistory(filePath) → { total, failures, success_rate, risk_adjustment }
10
- * extractPaths(text) → string[]
5
+ * Export: classifyRisk(paths) → { level, reason }
11
6
  */
12
7
 
13
- import { execSync } from 'child_process';
14
- import { existsSync, readFileSync } from 'fs';
15
- import { dirname, join } from 'path';
16
- import { fileURLToPath } from 'url';
17
-
18
- const __dirname = dirname(fileURLToPath(import.meta.url));
19
- const LEDGER_FILE = join(__dirname, 'decision-ledger.jsonl');
20
-
21
8
  const PATTERNS = [
22
9
  { level: 'critical', regex: /\b(auth|credential|secret|\.env|key[s]?|token[s]?|password|encrypt|certificate|cert[s]?|\.pem|\.key)\b/i, label: 'security-sensitive' },
23
10
  { level: 'high', regex: /\b(billing|payment|migration|deploy|ci[-/]cd|\.github\/workflows|security|permission|policy|schema\.prisma|schema\.sql|api[-_]?contract|openapi|swagger)\b/i, label: 'high-impact infrastructure' },
@@ -26,7 +13,6 @@ const PATTERNS = [
26
13
  ];
27
14
 
28
15
  const LEVEL_ORDER = { critical: 3, high: 2, medium: 1, low: 0 };
29
- const LEVEL_UP = { low: 'medium', medium: 'high', high: 'critical', critical: 'critical' };
30
16
 
31
17
  function classifyRisk(paths) {
32
18
  if (!paths || paths.length === 0) return { level: 'low', reason: 'no file paths detected' };
@@ -45,125 +31,6 @@ function classifyRisk(paths) {
45
31
  return highest;
46
32
  }
47
33
 
48
- /**
49
- * Count how many commits touched a file in the last N days using git log.
50
- * Returns { commits, isHot: commits > 10 }, or null if git is unavailable.
51
- */
52
- function getGitChurn(filePath, days = 30) {
53
- try {
54
- const output = execSync(
55
- `git log --oneline --since="${days} days ago" -- "${filePath}"`,
56
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }
57
- );
58
- const commits = output.split('\n').filter(Boolean).length;
59
- return { commits, isHot: commits > 10 };
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
- /**
66
- * Read decision-ledger.jsonl and compute success rate for entries that
67
- * touched this file path or its parent directory.
68
- *
69
- * Returns { total, failures, success_rate, risk_adjustment } where
70
- * risk_adjustment is 'escalate' if success_rate < 60% with 3+ entries,
71
- * 'normal' otherwise.
72
- */
73
- function getFileRiskHistory(filePath) {
74
- const empty = { total: 0, failures: 0, success_rate: 100, risk_adjustment: 'normal' };
75
- if (!existsSync(LEDGER_FILE)) return empty;
76
-
77
- let raw;
78
- try { raw = readFileSync(LEDGER_FILE, 'utf8'); } catch { return empty; }
79
-
80
- // Normalize the file path and compute its parent directory prefix
81
- const normalizedPath = filePath.replace(/\\/g, '/');
82
- const parentDir = normalizedPath.includes('/') ? normalizedPath.slice(0, normalizedPath.lastIndexOf('/')) : '';
83
-
84
- let total = 0;
85
- let failures = 0;
86
-
87
- for (const line of raw.split('\n').filter(Boolean)) {
88
- try {
89
- const entry = JSON.parse(line);
90
- if (entry.type !== 'outcome') continue;
91
-
92
- const files = entry.files_changed || entry.files_read || [];
93
- if (!Array.isArray(files)) continue;
94
-
95
- const matches = files.some(f => {
96
- const nf = String(f).replace(/\\/g, '/');
97
- return nf === normalizedPath ||
98
- nf.includes(normalizedPath) ||
99
- (parentDir && nf.includes(parentDir));
100
- });
101
-
102
- if (!matches) continue;
103
-
104
- total++;
105
- if (entry.success === false) failures++;
106
- } catch {}
107
- }
108
-
109
- if (total === 0) return empty;
110
-
111
- const success_rate = Math.round(((total - failures) / total) * 100);
112
- const risk_adjustment = (success_rate < 60 && total >= 3) ? 'escalate' : 'normal';
113
-
114
- return { total, failures, success_rate, risk_adjustment };
115
- }
116
-
117
- /**
118
- * Enhanced risk classifier that combines static patterns with empirical data.
119
- *
120
- * Returns { risk, basis, details } where:
121
- * risk — 'low' | 'medium' | 'high' | 'critical'
122
- * basis — 'static' | 'churn' | 'history' | 'churn+history'
123
- * details — { static_risk, churn_commits, history_success_rate }
124
- */
125
- function classifyRiskEnhanced(filePath) {
126
- // Step 1: static pattern classification
127
- const staticResult = classifyRisk([filePath]);
128
- let risk = staticResult.level;
129
- const details = {
130
- static_risk: risk,
131
- churn_commits: null,
132
- history_success_rate: null,
133
- };
134
-
135
- let bumpedByChurn = false;
136
- let bumpedByHistory = false;
137
-
138
- // Step 2: git churn check
139
- const churn = getGitChurn(filePath);
140
- if (churn !== null) {
141
- details.churn_commits = churn.commits;
142
- if (churn.isHot && risk !== 'critical') {
143
- risk = LEVEL_UP[risk];
144
- bumpedByChurn = true;
145
- }
146
- }
147
-
148
- // Step 3: file risk history check
149
- const history = getFileRiskHistory(filePath);
150
- details.history_success_rate = history.success_rate;
151
- if (history.risk_adjustment === 'escalate' && risk !== 'critical') {
152
- risk = LEVEL_UP[risk];
153
- bumpedByHistory = true;
154
- }
155
-
156
- // Cap at critical
157
- if (LEVEL_ORDER[risk] > LEVEL_ORDER['critical']) risk = 'critical';
158
-
159
- let basis = 'static';
160
- if (bumpedByChurn && bumpedByHistory) basis = 'churn+history';
161
- else if (bumpedByChurn) basis = 'churn';
162
- else if (bumpedByHistory) basis = 'history';
163
-
164
- return { risk, basis, details };
165
- }
166
-
167
34
  function extractPaths(text) {
168
35
  if (!text) return [];
169
36
  const matches = text.match(/(?:^|\s|["'`])([./~]?(?:[\w@.-]+\/)+[\w@.*-]+(?:\.\w+)?)/g);
@@ -171,4 +38,4 @@ function extractPaths(text) {
171
38
  return matches.map(m => m.trim().replace(/^["'`]/, ''));
172
39
  }
173
40
 
174
- export { classifyRisk, classifyRiskEnhanced, getGitChurn, getFileRiskHistory, extractPaths };
41
+ export { classifyRisk, extractPaths };