dual-brain 0.2.4 → 0.2.6

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/src/profile.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * getHeadModel(profile) → suggested head model string
16
16
  * detectCapabilities(cwd) → what we can actually verify
17
17
  * getOnboardingMessage(caps, ws) → honest 2-3 line status message
18
- * needsApiGuardrail(caps) → true if metered API key detected
18
+ * detectCapabilities(cwd) → available providers (subscription-based only)
19
19
  *
20
20
  * CLI:
21
21
  * node src/profile.mjs # show current profile
@@ -136,7 +136,7 @@ function detectEnvironment() {
136
136
  * @param {string} [cwd]
137
137
  * @returns {Promise<{
138
138
  * claude: { available: boolean, source: string|null },
139
- * openai: { available: boolean, source: string|null, metered: boolean },
139
+ * openai: { available: boolean, source: string|null },
140
140
  * codex: { available: boolean, source: string|null },
141
141
  * replitTools: { available: boolean, checkpoints: boolean },
142
142
  * }>}
@@ -144,18 +144,15 @@ function detectEnvironment() {
144
144
  async function detectCapabilities(cwd) {
145
145
  const root = cwd || process.cwd();
146
146
 
147
- // --- Claude: running inside Claude Code or has ANTHROPIC_API_KEY or ~/.claude dir ---
147
+ // --- Claude: running inside Claude Code session or CLI installed ---
148
148
  let claudeAvailable = false;
149
149
  let claudeSource = null;
150
150
 
151
151
  if (process.env.CLAUDE_CODE) {
152
152
  claudeAvailable = true;
153
153
  claudeSource = 'claude-code';
154
- } else if (process.env.ANTHROPIC_API_KEY?.length > 0) {
155
- claudeAvailable = true;
156
- claudeSource = 'api-key';
157
154
  } else {
158
- // Check for ~/.claude directory (Claude Code installation)
155
+ // Check for ~/.claude directory (Claude Code installation) or Replit Claude
159
156
  const claudeDir = join(homedir(), '.claude');
160
157
  const replitClaudeDir = join(root, '.replit-tools', '.claude-persistent');
161
158
  if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
@@ -164,9 +161,6 @@ async function detectCapabilities(cwd) {
164
161
  }
165
162
  }
166
163
 
167
- // --- OpenAI: check for OPENAI_API_KEY presence (metered billing) ---
168
- const openaiAvailable = !!process.env.OPENAI_API_KEY?.length;
169
-
170
164
  // --- Codex: check if 'codex' is in PATH ---
171
165
  let codexAvailable = false;
172
166
  let codexSource = null;
@@ -200,9 +194,8 @@ async function detectCapabilities(cwd) {
200
194
  source: claudeSource,
201
195
  },
202
196
  openai: {
203
- available: openaiAvailable || codexAvailable,
204
- source: openaiAvailable ? 'api-key' : codexAvailable ? 'codex-cli' : null,
205
- metered: openaiAvailable && !codexAvailable,
197
+ available: codexAvailable,
198
+ source: codexAvailable ? 'codex-cli' : null,
206
199
  },
207
200
  codex: {
208
201
  available: codexAvailable,
@@ -215,18 +208,6 @@ async function detectCapabilities(cwd) {
215
208
  };
216
209
  }
217
210
 
218
- /**
219
- * Return true if any metered API key is detected.
220
- * When true, the system defaults to conservative API usage and should
221
- * confirm before expensive operations.
222
- *
223
- * @param {ReturnType<typeof detectCapabilities> extends Promise<infer T> ? T : never} capabilities
224
- * @returns {boolean}
225
- */
226
- function needsApiGuardrail(capabilities) {
227
- return !!(capabilities?.openai?.metered);
228
- }
229
-
230
211
  /**
231
212
  * Generate an honest 2-3 line onboarding/status message based on
232
213
  * what we can actually verify.
@@ -237,9 +218,8 @@ function needsApiGuardrail(capabilities) {
237
218
  */
238
219
  function getOnboardingMessage(capabilities, workStyle = 'balanced') {
239
220
  const found = [];
240
- if (capabilities?.claude?.available) found.push('Claude Code');
241
- if (capabilities?.openai?.available) found.push('OpenAI API');
242
- if (capabilities?.codex?.available && !capabilities?.openai?.available) found.push('Codex CLI');
221
+ if (capabilities?.claude?.available) found.push('Claude · subscription');
222
+ if (capabilities?.codex?.available) found.push('OpenAI · Codex subscription');
243
223
 
244
224
  const styleLabels = {
245
225
  'balanced': 'Balanced — smart routing, reviews on important changes',
@@ -258,16 +238,11 @@ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
258
238
  lines.push(`Found: ${found.join(', ')}`);
259
239
  lines.push(` Mode: ${modeLabel}`);
260
240
 
261
- // Tip: suggest OpenAI if only Claude is available
262
- if (capabilities?.claude?.available && !capabilities?.openai?.available && !capabilities?.codex?.available) {
241
+ // Tip: suggest Codex if only Claude is available
242
+ if (capabilities?.claude?.available && !capabilities?.codex?.available) {
263
243
  lines.push(' Tip: Run codex login for dual-brain collaboration');
264
244
  }
265
245
 
266
- // Warn about metered billing
267
- if (capabilities?.openai?.metered) {
268
- lines.push(' Note: OpenAI API key detected — usage is metered, guardrails enabled');
269
- }
270
-
271
246
  return lines.join('\n');
272
247
  }
273
248
 
@@ -393,9 +368,8 @@ async function runOnboarding(opts = {}) {
393
368
 
394
369
  // Show what we found honestly
395
370
  const foundProviders = [];
396
- if (capabilities.claude.available) foundProviders.push('Claude Code');
397
- if (capabilities.openai.available) foundProviders.push('OpenAI API (metered)');
398
- if (capabilities.codex.available && !capabilities.openai.available) foundProviders.push('Codex CLI');
371
+ if (capabilities.claude.available) foundProviders.push('Claude · subscription');
372
+ if (capabilities.codex.available) foundProviders.push('OpenAI · Codex subscription');
399
373
 
400
374
  if (foundProviders.length > 0) {
401
375
  process.stdout.write(`Detected: ${foundProviders.join(', ')}\n\n`);
@@ -405,15 +379,14 @@ async function runOnboarding(opts = {}) {
405
379
 
406
380
  // Enable providers based on what's available
407
381
  profile.providers.claude.enabled = capabilities.claude.available;
408
- profile.providers.openai.enabled = capabilities.openai.available || capabilities.codex.available;
409
- profile.apiGuardrail = needsApiGuardrail(capabilities);
382
+ profile.providers.openai.enabled = capabilities.codex.available;
410
383
 
411
384
  // If detection missed something, ask
412
- if (!capabilities.claude.available && !capabilities.openai.available && !capabilities.codex.available) {
413
- const q1 = (await ask('Which AI providers do you have access to?\n (1) Claude Code only (2) OpenAI API only (3) Both (4) Neither\n> ')).trim();
385
+ if (!capabilities.claude.available && !capabilities.codex.available) {
386
+ const q1 = (await ask('Which AI providers do you have access to?\n (1) Claude only (2) OpenAI Codex only (3) Both (4) Neither\n> ')).trim();
414
387
  if (q1 === '1') { profile.providers.claude.enabled = true; }
415
- else if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true; profile.apiGuardrail = true; }
416
- else if (q1 === '3') { profile.providers.claude.enabled = true; profile.providers.openai.enabled = true; profile.apiGuardrail = true; }
388
+ else if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true; }
389
+ else if (q1 === '3') { profile.providers.claude.enabled = true; profile.providers.openai.enabled = true; }
417
390
  }
418
391
 
419
392
  const q3 = (await ask('\nDefault work style?\n (1) Save usage (2) Balanced (3) Best quality\n> ')).trim();
@@ -546,19 +519,16 @@ async function autoSetup(cwd) {
546
519
  result.actions.push(`Claude: available (${capabilities.claude.source})`);
547
520
  } else {
548
521
  profile.providers.claude.enabled = false;
549
- result.warnings.push('Claude not detected — install Claude Code or set ANTHROPIC_API_KEY');
522
+ result.warnings.push('Claude not detected — run: claude login');
550
523
  }
551
524
 
552
525
  // OpenAI / Codex
553
- if (capabilities.openai.available) {
526
+ if (capabilities.codex.available) {
554
527
  profile.providers.openai.enabled = true;
555
- result.actions.push('OpenAI: API key detected (metered billing — guardrails enabled)');
556
- } else if (capabilities.codex.available) {
557
- profile.providers.openai.enabled = true;
558
- result.actions.push('Codex CLI: available');
528
+ result.actions.push('Codex CLI: available (subscription)');
559
529
  } else {
560
530
  profile.providers.openai.enabled = false;
561
- result.warnings.push('OpenAI not detected — add OPENAI_API_KEY or install Codex CLI');
531
+ result.warnings.push('OpenAI not detected — run: codex login');
562
532
  }
563
533
 
564
534
  // Mode
@@ -568,7 +538,6 @@ async function autoSetup(cwd) {
568
538
  : 'solo-openai';
569
539
  profile.bias = 'balanced';
570
540
  profile.workStyle = 'balanced';
571
- profile.apiGuardrail = needsApiGuardrail(capabilities);
572
541
  profile.capabilities = capabilities;
573
542
  profile.detectedAt = new Date().toISOString();
574
543
 
@@ -909,17 +878,6 @@ export async function checkCredentialHealth(cred, cwd = process.cwd()) {
909
878
  } catch {
910
879
  health = 'healthy'; // cli works, auth check unavailable
911
880
  }
912
- } else if (cred.auth_type === 'api_key') {
913
- if (cred.source === 'replit_secret') {
914
- try {
915
- const { hasSecret } = await import('./replit.mjs');
916
- health = hasSecret(cred.env_var || cred.id.toUpperCase().replace(/-/g, '_')) ? 'healthy' : 'unhealthy';
917
- } catch { health = 'unknown'; }
918
- } else {
919
- // env source
920
- const varName = cred.env_var || cred.id.toUpperCase().replace(/-/g, '_');
921
- health = (process.env[varName] && process.env[varName].length > 0) ? 'healthy' : 'unhealthy';
922
- }
923
881
  }
924
882
  } catch { health = 'unknown'; }
925
883
  return { ...cred, health, last_checked_at: new Date().toISOString() };
@@ -949,64 +907,24 @@ export async function detectCredentials(cwd = process.cwd()) {
949
907
  });
950
908
  }
951
909
 
952
- // ANTHROPIC_API_KEY
953
- if (process.env.ANTHROPIC_API_KEY) {
954
- found.push({
955
- id: 'anthropic-api-key',
956
- provider: 'claude',
957
- auth_type: 'api_key',
958
- source: 'env',
959
- env_var: 'ANTHROPIC_API_KEY',
960
- owner: 'user',
961
- scope: 'local',
962
- plan_hint: null,
963
- enabled: true,
964
- health: 'healthy',
965
- last_checked_at: new Date().toISOString(),
966
- });
967
- }
968
-
969
- // OPENAI_API_KEY (env)
970
- if (process.env.OPENAI_API_KEY) {
910
+ // Codex CLI (subscription-based OpenAI access)
911
+ try {
912
+ execSync('which codex', { stdio: 'pipe', timeout: 2000 });
913
+ let codexHealth = 'unknown';
914
+ try { execSync('codex --version', { stdio: 'pipe', timeout: 3000 }); codexHealth = 'healthy'; } catch { codexHealth = 'degraded'; }
971
915
  found.push({
972
- id: 'openai-api-key',
916
+ id: 'openai-codex-cli',
973
917
  provider: 'openai',
974
- auth_type: 'api_key',
975
- source: 'env',
976
- env_var: 'OPENAI_API_KEY',
918
+ auth_type: 'cli_oauth',
919
+ source: 'local_cli',
977
920
  owner: 'user',
978
921
  scope: 'local',
979
922
  plan_hint: null,
980
923
  enabled: true,
981
- health: 'healthy',
924
+ health: codexHealth,
982
925
  last_checked_at: new Date().toISOString(),
983
926
  });
984
- }
985
-
986
- // Replit secrets
987
- try {
988
- const { hasSecret } = await import('./replit.mjs');
989
- const secretsToCheck = ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'];
990
- for (const name of secretsToCheck) {
991
- if (!process.env[name] && hasSecret(name)) {
992
- const provider = name.startsWith('OPENAI') ? 'openai' : 'claude';
993
- const id = name.toLowerCase().replace(/_/g, '-') + '-replit';
994
- found.push({
995
- id,
996
- provider,
997
- auth_type: 'api_key',
998
- source: 'replit_secret',
999
- env_var: name,
1000
- owner: 'user',
1001
- scope: 'workspace',
1002
- plan_hint: null,
1003
- enabled: true,
1004
- health: 'healthy',
1005
- last_checked_at: new Date().toISOString(),
1006
- });
1007
- }
1008
- }
1009
- } catch { /* replit.mjs unavailable */ }
927
+ } catch { /* codex not in PATH */ }
1010
928
 
1011
929
  return found;
1012
930
  }
@@ -1120,12 +1038,12 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
1120
1038
  budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
1121
1039
 
1122
1040
  try {
1123
- // available: claude CLI or CLAUDE_CODE env or replit-tools claude dir
1041
+ // available: CLAUDE_CODE env, claude CLI, or replit-tools claude dir
1124
1042
  const claudeDir = join(homedir(), '.claude');
1125
1043
  const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
1126
- if (process.env.CLAUDE_CODE || process.env.ANTHROPIC_API_KEY) {
1044
+ if (process.env.CLAUDE_CODE) {
1127
1045
  claudeProvider.available = true;
1128
- claudeProvider.source = process.env.ANTHROPIC_API_KEY ? 'env' : 'credentials';
1046
+ claudeProvider.source = 'credentials';
1129
1047
  } else if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
1130
1048
  claudeProvider.available = true;
1131
1049
  claudeProvider.source = existsSync(replitClaudeDir) ? 'replit-tools' : 'credentials';
@@ -1164,15 +1082,12 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
1164
1082
  budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
1165
1083
 
1166
1084
  try {
1167
- let hasSecret = false;
1168
- try { const { hasSecret: hs } = await import('./replit.mjs'); hasSecret = hs('OPENAI_API_KEY'); } catch { hasSecret = !!(process.env.OPENAI_API_KEY); }
1169
-
1170
1085
  let codexAvailable = false;
1171
1086
  try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); codexAvailable = true; } catch { /* not in PATH */ }
1172
1087
 
1173
- openaiProvider.available = hasSecret || codexAvailable;
1174
- openaiProvider.authenticated = hasSecret;
1175
- openaiProvider.source = hasSecret ? 'env' : codexAvailable ? 'codex-config' : 'none';
1088
+ openaiProvider.available = codexAvailable;
1089
+ openaiProvider.authenticated = codexAvailable;
1090
+ openaiProvider.source = codexAvailable ? 'codex-cli' : 'none';
1176
1091
  } catch { /* detection failed */ }
1177
1092
 
1178
1093
  openaiProvider.plan = normalizePlan(orchProv.openai?.subscription ?? orchSubs.openai?.plan);
@@ -1387,7 +1302,7 @@ async function main() {
1387
1302
  `head model : ${getHeadModel(profile)}`,
1388
1303
  `providers : ${providers.map(p => p.name).join(', ') || 'none'}`,
1389
1304
  `prefs : ${profile.preferences?.filter(p => p.enabled).length || 0} active`,
1390
- `guardrail : ${needsApiGuardrail(caps) ? 'enabled (metered API key detected)' : 'off'}`,
1305
+ `guardrail : off`,
1391
1306
  '',
1392
1307
  getOnboardingMessage(caps, profile.workStyle || profile.bias),
1393
1308
  ].forEach(l => process.stdout.write(l + '\n'));
@@ -1404,7 +1319,7 @@ export {
1404
1319
  loadProfile, saveProfile, ensureProfile, runOnboarding,
1405
1320
  rememberPreference, forgetPreference, getActivePreferences,
1406
1321
  getAvailableProviders, isSoloBrain, getHeadModel,
1407
- detectCapabilities, getOnboardingMessage, needsApiGuardrail,
1322
+ detectCapabilities, getOnboardingMessage,
1408
1323
  syncPreferencesToMemory,
1409
1324
  detectAuth, detectEnvironment,
1410
1325
  autoSetup, autoRefreshToken,
package/src/replit.mjs CHANGED
@@ -339,7 +339,7 @@ const SYSTEM_PREFIXES = [
339
339
  ];
340
340
 
341
341
  const KNOWN_SECRET_NAMES = [
342
- 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'DATABASE_URL', 'REPLIT_DB_URL',
342
+ 'DATABASE_URL', 'REPLIT_DB_URL',
343
343
  'GITHUB_TOKEN', 'GITHUB_API_TOKEN', 'NPM_TOKEN', 'NPM_AUTH_TOKEN',
344
344
  'STRIPE_SECRET_KEY', 'STRIPE_API_KEY', 'AWS_ACCESS_KEY_ID',
345
345
  'AWS_SECRET_ACCESS_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS',
package/src/repo.mjs CHANGED
@@ -283,6 +283,159 @@ export function getLintCommand(cwd = process.cwd()) {
283
283
  return detectRepo(cwd).commands.lint;
284
284
  }
285
285
 
286
+ // ─── Ownership hints ──────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Return the last git author, last-modified date, and commit count for a file.
290
+ * @param {string} filePath
291
+ * @param {string} [cwd]
292
+ * @returns {{ lastAuthor: string, lastModified: string, totalCommits: number }|null}
293
+ */
294
+ export function getFileOwnership(filePath, cwd) {
295
+ try {
296
+ const blame = execSync(`git log --format="%an" -1 -- "${filePath}"`, { cwd, encoding: 'utf8', timeout: 5000 }).trim();
297
+ const lastDate = execSync(`git log --format="%ci" -1 -- "${filePath}"`, { cwd, encoding: 'utf8', timeout: 5000 }).trim();
298
+ const commitCount = parseInt(execSync(`git rev-list --count HEAD -- "${filePath}"`, { cwd, encoding: 'utf8', timeout: 5000 }).trim()) || 0;
299
+ return { lastAuthor: blame, lastModified: lastDate, totalCommits: commitCount };
300
+ } catch { return null; }
301
+ }
302
+
303
+ // ─── Dependency edges ─────────────────────────────────────────────────────────
304
+
305
+ /**
306
+ * Extract import/require edges from a source file.
307
+ * @param {string} filePath — relative path from cwd
308
+ * @param {string} [cwd]
309
+ * @returns {{ local: string[], external: string[], total: number }}
310
+ */
311
+ export function getDependencyEdges(filePath, cwd) {
312
+ try {
313
+ const content = readFileSync(join(cwd || process.cwd(), filePath), 'utf8');
314
+ const imports = [];
315
+ // ES module imports
316
+ for (const match of content.matchAll(/import\s+.*?from\s+['"]([^'"]+)['"]/g)) {
317
+ imports.push(match[1]);
318
+ }
319
+ // Dynamic imports
320
+ for (const match of content.matchAll(/import\(['"]([^'"]+)['"]\)/g)) {
321
+ imports.push(match[1]);
322
+ }
323
+ // CommonJS requires
324
+ for (const match of content.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
325
+ imports.push(match[1]);
326
+ }
327
+ const local = imports.filter(i => i.startsWith('.') || i.startsWith('/'));
328
+ const external = imports.filter(i => !i.startsWith('.') && !i.startsWith('/'));
329
+ return { local, external, total: imports.length };
330
+ } catch { return { local: [], external: [], total: 0 }; }
331
+ }
332
+
333
+ // ─── Test mapping ─────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Find test files whose name matches the source file's base name.
337
+ * @param {string} filePath
338
+ * @param {string} [cwd]
339
+ * @returns {string[]}
340
+ */
341
+ export function findRelatedTests(filePath, cwd) {
342
+ const root = cwd || process.cwd();
343
+ const base = filePath.replace(/\.(mjs|js|ts|tsx|jsx)$/, '');
344
+ const name = base.split('/').pop();
345
+
346
+ const found = [];
347
+ try {
348
+ const allTests = execSync(
349
+ `find . -type f \\( -name "*.test.*" -o -name "*.spec.*" -o -path "*/tests/*" -o -path "*/test/*" -o -path "*/__tests__/*" \\) -not -path "*/node_modules/*"`,
350
+ { cwd: root, encoding: 'utf8', timeout: 5000 }
351
+ ).trim().split('\n').filter(Boolean);
352
+
353
+ for (const t of allTests) {
354
+ if (t.includes(name)) found.push(t.replace(/^\.\//, ''));
355
+ }
356
+ } catch {}
357
+
358
+ return found;
359
+ }
360
+
361
+ // ─── Risk hotspots ────────────────────────────────────────────────────────────
362
+
363
+ /**
364
+ * Return the files with highest churn × complexity risk in the last N days.
365
+ * @param {string} [cwd]
366
+ * @param {{ days?: number, limit?: number }} [opts]
367
+ * @returns {Array<{ file: string, changeCount: number, lineCount: number, risk: number }>}
368
+ */
369
+ export function getRiskHotspots(cwd, opts = {}) {
370
+ const { days = 30, limit = 10 } = opts;
371
+ const root = cwd || process.cwd();
372
+ try {
373
+ const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0];
374
+ const log = execSync(
375
+ `git log --since="${since}" --name-only --pretty=format: | sort | uniq -c | sort -rn | head -${limit * 2}`,
376
+ { cwd: root, encoding: 'utf8', timeout: 10000 }
377
+ ).trim();
378
+
379
+ const hotspots = [];
380
+ for (const line of log.split('\n').filter(Boolean)) {
381
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
382
+ if (match) {
383
+ const changeCount = parseInt(match[1]);
384
+ const file = match[2];
385
+ if (changeCount >= 3 && existsSync(join(root, file))) {
386
+ let lineCount = 0;
387
+ try {
388
+ lineCount = readFileSync(join(root, file), 'utf8').split('\n').length;
389
+ } catch {}
390
+ hotspots.push({ file, changeCount, lineCount, risk: changeCount * Math.log2(Math.max(lineCount, 1)) });
391
+ }
392
+ }
393
+ }
394
+
395
+ return hotspots.sort((a, b) => b.risk - a.risk).slice(0, limit);
396
+ } catch { return []; }
397
+ }
398
+
399
+ // ─── Primary language detection ───────────────────────────────────────────────
400
+
401
+ function detectPrimaryLanguage(cwd) {
402
+ try {
403
+ const files = execSync(
404
+ 'git ls-files --cached | grep -oE "\\.[a-zA-Z]+$" | sort | uniq -c | sort -rn | head -5',
405
+ { cwd, encoding: 'utf8', timeout: 5000 }
406
+ ).trim();
407
+ const match = files.split('\n')[0]?.trim().match(/^\d+\s+\.(.+)$/);
408
+ const ext = match?.[1];
409
+ const langMap = {
410
+ js: 'JavaScript', mjs: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript',
411
+ py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java',
412
+ kt: 'Kotlin', swift: 'Swift', cpp: 'C++', c: 'C',
413
+ };
414
+ return langMap[ext] || ext || 'unknown';
415
+ } catch { return 'unknown'; }
416
+ }
417
+
418
+ // ─── Repo intelligence ────────────────────────────────────────────────────────
419
+
420
+ /**
421
+ * Return consolidated repo intelligence for routing decisions.
422
+ * @param {string} [cwd]
423
+ * @returns {object}
424
+ */
425
+ export function getRepoIntelligence(cwd) {
426
+ const root = cwd || process.cwd();
427
+ const cache = loadRepoCache(root);
428
+ const hotspots = getRiskHotspots(root);
429
+
430
+ return {
431
+ ...cache,
432
+ hotspots,
433
+ hasTests: hotspots.some(h => h.file.includes('test')),
434
+ primaryLanguage: detectPrimaryLanguage(root),
435
+ repoSize: cache?.fileCount || 0,
436
+ };
437
+ }
438
+
286
439
  // ─── CLI (direct invocation) ──────────────────────────────────────────────────
287
440
 
288
441
  const isMain = process.argv[1]?.endsWith('repo.mjs');