dual-brain 0.2.3 → 0.2.5

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
@@ -151,9 +151,9 @@ async function detectCapabilities(cwd) {
151
151
  if (process.env.CLAUDE_CODE) {
152
152
  claudeAvailable = true;
153
153
  claudeSource = 'claude-code';
154
- } else if (process.env.ANTHROPIC_API_KEY) {
154
+ } else if (process.env.ANTHROPIC_API_KEY?.length > 0) {
155
155
  claudeAvailable = true;
156
- claudeSource = 'env-key';
156
+ claudeSource = 'api-key';
157
157
  } else {
158
158
  // Check for ~/.claude directory (Claude Code installation)
159
159
  const claudeDir = join(homedir(), '.claude');
@@ -164,9 +164,8 @@ async function detectCapabilities(cwd) {
164
164
  }
165
165
  }
166
166
 
167
- // --- OpenAI: check for OPENAI_API_KEY (metered billing) ---
168
- const openaiKey = process.env.OPENAI_API_KEY;
169
- const openaiAvailable = !!(openaiKey && openaiKey.length > 0);
167
+ // --- OpenAI: check for OPENAI_API_KEY presence (metered billing) ---
168
+ const openaiAvailable = !!process.env.OPENAI_API_KEY?.length;
170
169
 
171
170
  // --- Codex: check if 'codex' is in PATH ---
172
171
  let codexAvailable = false;
@@ -201,9 +200,9 @@ async function detectCapabilities(cwd) {
201
200
  source: claudeSource,
202
201
  },
203
202
  openai: {
204
- available: openaiAvailable,
205
- source: openaiAvailable ? 'env-key' : null,
206
- metered: openaiAvailable, // API key = metered billing
203
+ available: openaiAvailable || codexAvailable,
204
+ source: openaiAvailable ? 'api-key' : codexAvailable ? 'codex-cli' : null,
205
+ metered: openaiAvailable && !codexAvailable,
207
206
  },
208
207
  codex: {
209
208
  available: codexAvailable,
@@ -252,7 +251,7 @@ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
252
251
  const lines = [];
253
252
  if (found.length === 0) {
254
253
  lines.push('No providers detected');
255
- lines.push(' Set ANTHROPIC_API_KEY or install Claude Code to get started');
254
+ lines.push(' Run: claude login or install Claude Code to get started');
256
255
  return lines.join('\n');
257
256
  }
258
257
 
@@ -261,7 +260,7 @@ function getOnboardingMessage(capabilities, workStyle = 'balanced') {
261
260
 
262
261
  // Tip: suggest OpenAI if only Claude is available
263
262
  if (capabilities?.claude?.available && !capabilities?.openai?.available && !capabilities?.codex?.available) {
264
- lines.push(' Tip: Add OPENAI_API_KEY for dual-brain collaboration');
263
+ lines.push(' Tip: Run codex login for dual-brain collaboration');
265
264
  }
266
265
 
267
266
  // Warn about metered billing
@@ -814,6 +813,223 @@ function listSubscriptions(cwd) {
814
813
  return profile.providers || {};
815
814
  }
816
815
 
816
+ // ---------------------------------------------------------------------------
817
+ // Credential Registry
818
+ // ---------------------------------------------------------------------------
819
+
820
+ const credentialsPath = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'credentials.json');
821
+
822
+ function defaultCredentials() {
823
+ return { version: 1, credentials: [] };
824
+ }
825
+
826
+ export function loadCredentials(cwd = process.cwd()) {
827
+ try {
828
+ const p = credentialsPath(cwd);
829
+ if (!existsSync(p)) return defaultCredentials();
830
+ return JSON.parse(readFileSync(p, 'utf8'));
831
+ } catch {
832
+ return defaultCredentials();
833
+ }
834
+ }
835
+
836
+ export function saveCredentials(data, cwd = process.cwd()) {
837
+ try {
838
+ const p = credentialsPath(cwd);
839
+ const dir = p.slice(0, p.lastIndexOf('/'));
840
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
841
+ // Ensure no raw secret values are stored
842
+ const safe = {
843
+ ...data,
844
+ credentials: (data.credentials || []).map(c => {
845
+ const clean = { ...c };
846
+ delete clean.secret;
847
+ delete clean.token;
848
+ delete clean.api_key;
849
+ delete clean.password;
850
+ return clean;
851
+ }),
852
+ };
853
+ const tmp = p + '.tmp.' + process.pid;
854
+ writeFileSync(tmp, JSON.stringify(safe, null, 2) + '\n');
855
+ renameSync(tmp, p);
856
+ return p;
857
+ } catch { /* non-fatal */ }
858
+ }
859
+
860
+ export function addCredential(cred, cwd = process.cwd()) {
861
+ const required = ['id', 'provider', 'auth_type', 'source'];
862
+ for (const f of required) {
863
+ if (!cred[f]) throw new Error(`addCredential: missing required field '${f}'`);
864
+ }
865
+ const data = loadCredentials(cwd);
866
+ const idx = data.credentials.findIndex(c => c.id === cred.id);
867
+ const entry = {
868
+ id: cred.id,
869
+ provider: cred.provider,
870
+ auth_type: cred.auth_type,
871
+ source: cred.source,
872
+ owner: cred.owner || 'user',
873
+ scope: cred.scope || 'local',
874
+ plan_hint: cred.plan_hint || null,
875
+ enabled: cred.enabled !== false,
876
+ health: cred.health || 'unknown',
877
+ last_checked_at: cred.last_checked_at || null,
878
+ };
879
+ if (idx >= 0) data.credentials[idx] = entry;
880
+ else data.credentials.push(entry);
881
+ saveCredentials(data, cwd);
882
+ return entry;
883
+ }
884
+
885
+ export function removeCredential(id, cwd = process.cwd()) {
886
+ const data = loadCredentials(cwd);
887
+ data.credentials = data.credentials.filter(c => c.id !== id);
888
+ saveCredentials(data, cwd);
889
+ }
890
+
891
+ export function getHealthyCredentials(provider = null, cwd = process.cwd()) {
892
+ const data = loadCredentials(cwd);
893
+ return data.credentials.filter(c =>
894
+ c.enabled !== false &&
895
+ c.health !== 'unhealthy' &&
896
+ (provider === null || c.provider === provider)
897
+ );
898
+ }
899
+
900
+ export async function checkCredentialHealth(cred, cwd = process.cwd()) {
901
+ let health = 'unknown';
902
+ try {
903
+ if (cred.auth_type === 'cli_oauth') {
904
+ try { execSync('claude --version', { stdio: 'pipe', timeout: 3000 }); } catch { return { ...cred, health: 'unhealthy', last_checked_at: new Date().toISOString() }; }
905
+ try {
906
+ const { getAuthStatus } = await import('./replit.mjs');
907
+ const status = getAuthStatus(cwd);
908
+ health = (status.available && status.tokenStatus !== 'expired') ? 'healthy' : 'degraded';
909
+ } catch {
910
+ health = 'healthy'; // cli works, auth check unavailable
911
+ }
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
+ }
924
+ } catch { health = 'unknown'; }
925
+ return { ...cred, health, last_checked_at: new Date().toISOString() };
926
+ }
927
+
928
+ export async function detectCredentials(cwd = process.cwd()) {
929
+ const found = [];
930
+
931
+ // Claude CLI / oauth
932
+ const claudeDir = join(homedir(), '.claude');
933
+ const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
934
+ const claudeAvail = process.env.CLAUDE_CODE || existsSync(claudeDir) || existsSync(replitClaudeDir);
935
+ if (claudeAvail) {
936
+ let health = 'unknown';
937
+ try { execSync('claude --version', { stdio: 'pipe', timeout: 3000 }); health = 'healthy'; } catch { health = 'degraded'; }
938
+ found.push({
939
+ id: 'claude-local-user',
940
+ provider: 'claude',
941
+ auth_type: 'cli_oauth',
942
+ source: 'local_cli',
943
+ owner: 'user',
944
+ scope: 'local',
945
+ plan_hint: null,
946
+ enabled: true,
947
+ health,
948
+ last_checked_at: new Date().toISOString(),
949
+ });
950
+ }
951
+
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) {
971
+ found.push({
972
+ id: 'openai-api-key',
973
+ provider: 'openai',
974
+ auth_type: 'api_key',
975
+ source: 'env',
976
+ env_var: 'OPENAI_API_KEY',
977
+ owner: 'user',
978
+ scope: 'local',
979
+ plan_hint: null,
980
+ enabled: true,
981
+ health: 'healthy',
982
+ last_checked_at: new Date().toISOString(),
983
+ });
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 */ }
1010
+
1011
+ return found;
1012
+ }
1013
+
1014
+ export function getCredentialSummary(cwd = process.cwd()) {
1015
+ const data = loadCredentials(cwd);
1016
+ const creds = data.credentials || [];
1017
+ const byProvider = { claude: 0, openai: 0 };
1018
+ let healthy = 0, degraded = 0;
1019
+ for (const c of creds) {
1020
+ if (c.enabled === false) continue;
1021
+ if (byProvider[c.provider] !== undefined) byProvider[c.provider]++;
1022
+ if (c.health === 'healthy') healthy++;
1023
+ else if (c.health === 'degraded' || c.health === 'unknown') degraded++;
1024
+ }
1025
+ const total = creds.filter(c => c.enabled !== false).length;
1026
+ let teamCapacity = 'none';
1027
+ if (healthy >= 4) teamCapacity = 'high';
1028
+ else if (healthy >= 2) teamCapacity = 'medium';
1029
+ else if (healthy >= 1) teamCapacity = 'low';
1030
+ return { total, byProvider, healthy, degraded, teamCapacity };
1031
+ }
1032
+
817
1033
  // ---------------------------------------------------------------------------
818
1034
  // CLI
819
1035
  // ---------------------------------------------------------------------------
@@ -894,6 +1110,10 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
894
1110
  return { pressure5h: pressure, pressure7d: pressure * 0.6 };
895
1111
  }
896
1112
 
1113
+ // ── Credential registry (when available, overrides detection) ─────────
1114
+ const _credData = loadCredentials(cwd);
1115
+ const _hasCreds = _credData.credentials && _credData.credentials.length > 0;
1116
+
897
1117
  // ── Claude provider ────────────────────────────────────────────────────
898
1118
  const claudeProvider = { available: false, authenticated: false, plan: 'unknown',
899
1119
  models: ['opus', 'sonnet', 'haiku'], health: 'healthy',
@@ -924,6 +1144,20 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
924
1144
  claudeProvider.health = claudeProvider.authenticated ? deriveHealth('claude') : 'down';
925
1145
  claudeProvider.budget = deriveBudget('claude');
926
1146
 
1147
+ // Override with registry data when credentials.json exists
1148
+ if (_hasCreds) {
1149
+ const claudeCreds = _credData.credentials.filter(c => c.provider === 'claude' && c.enabled !== false);
1150
+ if (claudeCreds.length > 0) {
1151
+ claudeProvider.available = true;
1152
+ claudeProvider.authenticated = claudeCreds.some(c => c.health === 'healthy');
1153
+ claudeProvider.health = claudeCreds.some(c => c.health === 'healthy') ? deriveHealth('claude')
1154
+ : claudeCreds.some(c => c.health === 'degraded') ? 'degraded' : 'down';
1155
+ const planHint = claudeCreds.find(c => c.plan_hint)?.plan_hint;
1156
+ if (planHint) claudeProvider.plan = normalizePlan(planHint);
1157
+ claudeProvider.source = claudeCreds[0].source;
1158
+ }
1159
+ }
1160
+
927
1161
  // ── OpenAI provider ────────────────────────────────────────────────────
928
1162
  const openaiProvider = { available: false, authenticated: false, plan: 'unknown',
929
1163
  models: ['gpt-5.5', 'o3', 'gpt-4o', 'gpt-4o-mini'], health: 'healthy',
@@ -945,6 +1179,20 @@ export async function getCapabilityManifest(cwd = process.cwd()) {
945
1179
  openaiProvider.health = openaiProvider.authenticated ? deriveHealth('openai') : 'down';
946
1180
  openaiProvider.budget = deriveBudget('openai');
947
1181
 
1182
+ // Override with registry data when credentials.json exists
1183
+ if (_hasCreds) {
1184
+ const openaiCreds = _credData.credentials.filter(c => c.provider === 'openai' && c.enabled !== false);
1185
+ if (openaiCreds.length > 0) {
1186
+ openaiProvider.available = true;
1187
+ openaiProvider.authenticated = openaiCreds.some(c => c.health === 'healthy');
1188
+ openaiProvider.health = openaiCreds.some(c => c.health === 'healthy') ? deriveHealth('openai')
1189
+ : openaiCreds.some(c => c.health === 'degraded') ? 'degraded' : 'down';
1190
+ const planHint = openaiCreds.find(c => c.plan_hint)?.plan_hint;
1191
+ if (planHint) openaiProvider.plan = normalizePlan(planHint);
1192
+ openaiProvider.source = openaiCreds[0].source;
1193
+ }
1194
+ }
1195
+
948
1196
  // ── Preferences ────────────────────────────────────────────────────────
949
1197
  let preferences = { bias: 'auto', forbiddenModels: [], preferredModels: [],
950
1198
  costBias: 0.5, confirmBeforeExpensive: false };
@@ -1163,4 +1411,5 @@ export {
1163
1411
  // backward-compat stubs (deprecated)
1164
1412
  detectExistingAuth, detectPlans, saveSubscription, listSubscriptions,
1165
1413
  defaultProfile,
1414
+ // credential registry (functions already exported inline above)
1166
1415
  };
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');