dual-brain 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/dispatch.mjs CHANGED
@@ -672,6 +672,20 @@ async function dispatch(input = {}) {
672
672
  // Safety gate: redact secrets before anything reaches a subprocess or log
673
673
  prompt = redact(prompt);
674
674
 
675
+ // ── Resume brief injection ───────────────────────────────────────────────────
676
+ // Inject the last session's receipt as context when no situationBrief is already set.
677
+ // This closes the receipt → brief → next session loop automatically.
678
+ if (!input.situationBrief) {
679
+ try {
680
+ const { buildResumeBrief } = await import('./receipt.mjs');
681
+ const brief = buildResumeBrief(cwd);
682
+ if (brief) {
683
+ input = { ...input, situationBrief: brief };
684
+ }
685
+ } catch { /* non-blocking */ }
686
+ }
687
+ // ── End resume brief injection ───────────────────────────────────────────────
688
+
675
689
  // ── Related session context injection ────────────────────────────────────────
676
690
  // Find past sessions related to this task and prepend a context block.
677
691
  // Only injected when confidence is high (score > 5). Fast: index-only, no JSONL parsing.
package/src/pipeline.mjs CHANGED
@@ -1134,6 +1134,12 @@ export async function runPipeline(trigger, prompt, options = {}) {
1134
1134
  return { success: false, gateFailure: 'outcome', reason: run.gates.outcome.reason, run };
1135
1135
  }
1136
1136
 
1137
+ // Post-session receipt — capture what happened and seed next session's context
1138
+ try {
1139
+ const { generateReceipt } = await import('./receipt.mjs');
1140
+ generateReceipt(run, cwd);
1141
+ } catch { /* non-blocking */ }
1142
+
1137
1143
  // Persist decision for future recall
1138
1144
  if (run.result && !run.result?.error) {
1139
1145
  try {
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,10 +813,535 @@ 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
  // ---------------------------------------------------------------------------
820
1036
 
1037
+ // ---------------------------------------------------------------------------
1038
+ // Capability Manifest — single runtime view of all provider/subscription state
1039
+ // ---------------------------------------------------------------------------
1040
+
1041
+ /** 60-second in-process cache for the manifest. */
1042
+ let _manifestCache = null;
1043
+ let _manifestCachedAt = 0;
1044
+ const MANIFEST_TTL_MS = 60_000;
1045
+
1046
+ /**
1047
+ * Build a normalized capability manifest that consolidates provider health,
1048
+ * subscription config, user preferences, policy, and learning data.
1049
+ *
1050
+ * @param {string} [cwd]
1051
+ * @returns {Promise<object>}
1052
+ */
1053
+ export async function getCapabilityManifest(cwd = process.cwd()) {
1054
+ const now = Date.now();
1055
+ if (_manifestCache && now - _manifestCachedAt < MANIFEST_TTL_MS) {
1056
+ return _manifestCache;
1057
+ }
1058
+
1059
+ // ── Read orchestrator.json for subscription config ─────────────────────
1060
+ let orchConfig = {};
1061
+ try {
1062
+ const orchPath = join(cwd, 'orchestrator.json');
1063
+ orchConfig = JSON.parse(readFileSync(orchPath, 'utf8'));
1064
+ } catch { /* missing or malformed — fall through */ }
1065
+
1066
+ const orchSubs = orchConfig.subscriptions ?? {};
1067
+ const orchProv = orchConfig.providers ?? {};
1068
+
1069
+ // ── Plan normalizer (orchestrator.json uses "$100", "max-5x", "pro" etc) ─
1070
+ function normalizePlan(raw) {
1071
+ if (!raw) return 'unknown';
1072
+ const s = String(raw).toLowerCase();
1073
+ if (s.includes('max') && s.includes('20')) return 'max20';
1074
+ if (s.includes('max') && (s.includes('5') || s.includes('5x'))) return 'max5';
1075
+ if (s.includes('pro')) return 'pro';
1076
+ if (s.includes('plus')) return 'plus';
1077
+ if (s === '$20' || s === '20') return 'pro';
1078
+ if (s === '$100' || s === '100') return 'max5';
1079
+ if (s === '$200' || s === '200') return 'max20';
1080
+ return 'unknown';
1081
+ }
1082
+
1083
+ // ── Health states ──────────────────────────────────────────────────────
1084
+ let healthStates = {};
1085
+ try {
1086
+ const { getHealth } = await import('./health.mjs');
1087
+ healthStates = getHealth(cwd).states ?? {};
1088
+ } catch { /* health.mjs unavailable */ }
1089
+
1090
+ function deriveHealth(providerKey) {
1091
+ // Aggregate across all model classes for the provider
1092
+ const entries = Object.entries(healthStates)
1093
+ .filter(([k]) => k.startsWith(providerKey + ':'))
1094
+ .map(([, v]) => v?.status ?? 'healthy');
1095
+ if (entries.length === 0) return 'healthy';
1096
+ if (entries.some(s => s === 'hot')) return 'rate-limited';
1097
+ if (entries.some(s => s === 'degraded')) return 'degraded';
1098
+ if (entries.some(s => s === 'probing')) return 'degraded';
1099
+ return 'healthy';
1100
+ }
1101
+
1102
+ // ── Budget pressure from health file (simple proxy) ────────────────────
1103
+ function deriveBudget(providerKey) {
1104
+ const hotEntries = Object.entries(healthStates)
1105
+ .filter(([k]) => k.startsWith(providerKey + ':'))
1106
+ .filter(([, v]) => v?.status === 'hot');
1107
+ if (hotEntries.length === 0) return { pressure5h: 0, pressure7d: 0 };
1108
+ // Clamp to 0.9 when hot — we don't have real token data here
1109
+ const pressure = Math.min(0.9, 0.5 + hotEntries.length * 0.15);
1110
+ return { pressure5h: pressure, pressure7d: pressure * 0.6 };
1111
+ }
1112
+
1113
+ // ── Credential registry (when available, overrides detection) ─────────
1114
+ const _credData = loadCredentials(cwd);
1115
+ const _hasCreds = _credData.credentials && _credData.credentials.length > 0;
1116
+
1117
+ // ── Claude provider ────────────────────────────────────────────────────
1118
+ const claudeProvider = { available: false, authenticated: false, plan: 'unknown',
1119
+ models: ['opus', 'sonnet', 'haiku'], health: 'healthy',
1120
+ budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
1121
+
1122
+ try {
1123
+ // available: claude CLI or CLAUDE_CODE env or replit-tools claude dir
1124
+ const claudeDir = join(homedir(), '.claude');
1125
+ const replitClaudeDir = join(cwd, '.replit-tools', '.claude-persistent');
1126
+ if (process.env.CLAUDE_CODE || process.env.ANTHROPIC_API_KEY) {
1127
+ claudeProvider.available = true;
1128
+ claudeProvider.source = process.env.ANTHROPIC_API_KEY ? 'env' : 'credentials';
1129
+ } else if (existsSync(claudeDir) || existsSync(replitClaudeDir)) {
1130
+ claudeProvider.available = true;
1131
+ claudeProvider.source = existsSync(replitClaudeDir) ? 'replit-tools' : 'credentials';
1132
+ } else {
1133
+ try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); claudeProvider.available = true; claudeProvider.source = 'credentials'; } catch { /* not found */ }
1134
+ }
1135
+
1136
+ // authenticated: use getAuthHealthStatus
1137
+ const { getAuthHealthStatus } = await import('./health.mjs');
1138
+ const authStatus = await getAuthHealthStatus(cwd);
1139
+ claudeProvider.authenticated = authStatus.ok;
1140
+ if (authStatus.source === 'replit-tools') claudeProvider.source = 'replit-tools';
1141
+ } catch { /* getAuthHealthStatus unavailable */ }
1142
+
1143
+ claudeProvider.plan = normalizePlan(orchProv.claude?.subscription ?? orchSubs.claude?.plan);
1144
+ claudeProvider.health = claudeProvider.authenticated ? deriveHealth('claude') : 'down';
1145
+ claudeProvider.budget = deriveBudget('claude');
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
+
1161
+ // ── OpenAI provider ────────────────────────────────────────────────────
1162
+ const openaiProvider = { available: false, authenticated: false, plan: 'unknown',
1163
+ models: ['gpt-5.5', 'o3', 'gpt-4o', 'gpt-4o-mini'], health: 'healthy',
1164
+ budget: { pressure5h: 0, pressure7d: 0 }, source: 'none' };
1165
+
1166
+ 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
+ let codexAvailable = false;
1171
+ try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); codexAvailable = true; } catch { /* not in PATH */ }
1172
+
1173
+ openaiProvider.available = hasSecret || codexAvailable;
1174
+ openaiProvider.authenticated = hasSecret;
1175
+ openaiProvider.source = hasSecret ? 'env' : codexAvailable ? 'codex-config' : 'none';
1176
+ } catch { /* detection failed */ }
1177
+
1178
+ openaiProvider.plan = normalizePlan(orchProv.openai?.subscription ?? orchSubs.openai?.plan);
1179
+ openaiProvider.health = openaiProvider.authenticated ? deriveHealth('openai') : 'down';
1180
+ openaiProvider.budget = deriveBudget('openai');
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
+
1196
+ // ── Preferences ────────────────────────────────────────────────────────
1197
+ let preferences = { bias: 'auto', forbiddenModels: [], preferredModels: [],
1198
+ costBias: 0.5, confirmBeforeExpensive: false };
1199
+ try {
1200
+ const profile = loadProfile(cwd);
1201
+ const bias = profile.bias ?? profile.workStyle ?? 'auto';
1202
+ preferences.bias = ['auto','balanced','cost-saver','quality-first'].includes(bias) ? bias : 'auto';
1203
+ preferences.forbiddenModels = profile.forbiddenModels ?? [];
1204
+ preferences.preferredModels = profile.preferredModels ?? [];
1205
+ preferences.costBias = profile.costBias ?? (bias === 'cost-saver' ? 0.8 : bias === 'quality-first' ? 0.1 : 0.5);
1206
+ preferences.confirmBeforeExpensive = profile.apiGuardrail ?? false;
1207
+ } catch { /* profile unavailable */ }
1208
+
1209
+ // ── Policy ─────────────────────────────────────────────────────────────
1210
+ const policy = {
1211
+ highRiskRequiresBestAvailable: true,
1212
+ failoverMode: 'tell',
1213
+ dualBrainThreshold: 'high',
1214
+ };
1215
+
1216
+ // ── Learning ───────────────────────────────────────────────────────────
1217
+ let learning = {};
1218
+ try {
1219
+ const { getModelSuccessRates } = await import('./doctor.mjs');
1220
+ learning = getModelSuccessRates(cwd);
1221
+ } catch { /* doctor.mjs unavailable */ }
1222
+
1223
+ // ── Setup summary ──────────────────────────────────────────────────────
1224
+ const hasAnyProvider = (claudeProvider.available && claudeProvider.authenticated) ||
1225
+ (openaiProvider.available && openaiProvider.authenticated);
1226
+
1227
+ let recommendedAction = null;
1228
+ if (!claudeProvider.available && !openaiProvider.available) {
1229
+ recommendedAction = 'connect-claude';
1230
+ } else if (!claudeProvider.authenticated && !openaiProvider.authenticated) {
1231
+ recommendedAction = 'refresh-auth';
1232
+ } else if (!openaiProvider.available) {
1233
+ recommendedAction = 'connect-openai';
1234
+ }
1235
+
1236
+ const manifest = {
1237
+ providers: { claude: claudeProvider, openai: openaiProvider },
1238
+ preferences,
1239
+ policy,
1240
+ learning,
1241
+ setup: {
1242
+ hasAnyProvider,
1243
+ recommendedAction,
1244
+ zeroProviderMode: !hasAnyProvider,
1245
+ },
1246
+ timestamp: new Date().toISOString(),
1247
+ };
1248
+
1249
+ _manifestCache = manifest;
1250
+ _manifestCachedAt = now;
1251
+ return manifest;
1252
+ }
1253
+
1254
+ /**
1255
+ * Compute the effective routing policy for a specific task, applying rules in order:
1256
+ * 1. Safety constraints (high-risk → best available model)
1257
+ * 2. Provider availability
1258
+ * 3. Task tier fit (search→haiku, execute→sonnet, think→opus)
1259
+ * 4. User preferences (cost bias, forbidden models)
1260
+ * 5. Learning (prefer models with ≥90% success rate for this task type)
1261
+ *
1262
+ * @param {object} manifest — from getCapabilityManifest()
1263
+ * @param {{ tier?: string, risk?: string, taskType?: string }} taskContext
1264
+ * @returns {{ provider: string, model: string, tier: string, reason: string, overrides: string[] }}
1265
+ */
1266
+ export function getEffectivePolicy(manifest, taskContext = {}) {
1267
+ const { providers, preferences, policy, learning } = manifest;
1268
+ const tier = taskContext.tier ?? 'execute';
1269
+ const risk = taskContext.risk ?? 'medium';
1270
+ const taskType = taskContext.taskType ?? 'general';
1271
+ const overrides = [];
1272
+
1273
+ // Tier → default model mapping
1274
+ const tierModelMap = { search: 'haiku', execute: 'sonnet', think: 'opus' };
1275
+ let preferredModel = tierModelMap[tier] ?? 'sonnet';
1276
+ let preferredProvider = 'claude';
1277
+
1278
+ // 1. Safety: high/critical risk → best available model
1279
+ if (policy.highRiskRequiresBestAvailable && (risk === 'high' || risk === 'critical')) {
1280
+ preferredModel = 'opus';
1281
+ overrides.push(`risk=${risk} → upgraded to opus`);
1282
+ }
1283
+
1284
+ // 2. Provider availability — fall back to openai if claude is down
1285
+ const claudeOk = providers.claude.available && providers.claude.authenticated &&
1286
+ providers.claude.health !== 'down';
1287
+ const openaiOk = providers.openai.available && providers.openai.authenticated &&
1288
+ providers.openai.health !== 'down';
1289
+
1290
+ if (!claudeOk && openaiOk) {
1291
+ preferredProvider = 'openai';
1292
+ // Remap model names for openai
1293
+ const openaiTierMap = { search: 'gpt-4o-mini', execute: 'gpt-4o', think: 'gpt-5.5' };
1294
+ preferredModel = risk === 'high' || risk === 'critical' ? 'gpt-5.5' : (openaiTierMap[tier] ?? 'gpt-4o');
1295
+ overrides.push('claude unavailable → routed to openai');
1296
+ } else if (!claudeOk && !openaiOk) {
1297
+ return { provider: 'none', model: 'none', tier, reason: 'no providers available', overrides };
1298
+ }
1299
+
1300
+ // 3. Task fit already applied via tierModelMap above
1301
+
1302
+ // 4. User preferences: forbidden models, cost bias
1303
+ const forbidden = preferences.forbiddenModels ?? [];
1304
+ if (forbidden.includes(preferredModel)) {
1305
+ // Downgrade one step
1306
+ const fallback = preferredProvider === 'claude'
1307
+ ? (preferredModel === 'opus' ? 'sonnet' : 'haiku')
1308
+ : (preferredModel === 'gpt-5.5' ? 'gpt-4o' : 'gpt-4o-mini');
1309
+ overrides.push(`${preferredModel} forbidden → downgraded to ${fallback}`);
1310
+ preferredModel = fallback;
1311
+ }
1312
+
1313
+ if (preferences.costBias > 0.7 && preferredModel === 'opus' && risk !== 'high' && risk !== 'critical') {
1314
+ preferredModel = 'sonnet';
1315
+ overrides.push('cost-bias > 0.7 → downgraded from opus to sonnet');
1316
+ }
1317
+
1318
+ // 5. Learning: if another model has ≥90% success for this task type, prefer it
1319
+ const successRates = learning ?? {};
1320
+ let bestLearnedModel = null;
1321
+ let bestRate = 0.9; // threshold
1322
+ for (const [model, stats] of Object.entries(successRates)) {
1323
+ if (stats.rate >= bestRate && stats.total >= 5 && !forbidden.includes(model)) {
1324
+ // Only prefer if it's on the right provider
1325
+ const isClaudeModel = ['opus', 'sonnet', 'haiku'].includes(model);
1326
+ if ((preferredProvider === 'claude' && isClaudeModel) ||
1327
+ (preferredProvider === 'openai' && !isClaudeModel)) {
1328
+ bestLearnedModel = model;
1329
+ bestRate = stats.rate;
1330
+ }
1331
+ }
1332
+ }
1333
+ if (bestLearnedModel && bestLearnedModel !== preferredModel) {
1334
+ overrides.push(`learning: ${bestLearnedModel} has ${Math.round(bestRate * 100)}% success → preferred`);
1335
+ preferredModel = bestLearnedModel;
1336
+ }
1337
+
1338
+ const reason = overrides.length > 0
1339
+ ? overrides[0]
1340
+ : `tier=${tier} → ${preferredProvider}/${preferredModel}`;
1341
+
1342
+ return { provider: preferredProvider, model: preferredModel, tier, reason, overrides };
1343
+ }
1344
+
821
1345
  async function main() {
822
1346
  const args = process.argv.slice(2);
823
1347
  const cwd = process.cwd();
@@ -887,4 +1411,5 @@ export {
887
1411
  // backward-compat stubs (deprecated)
888
1412
  detectExistingAuth, detectPlans, saveSubscription, listSubscriptions,
889
1413
  defaultProfile,
1414
+ // credential registry (functions already exported inline above)
890
1415
  };