dual-brain 0.1.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.
Files changed (68) hide show
  1. package/AGENTS.md +97 -0
  2. package/CLAUDE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +197 -0
  5. package/agents/implementer.md +22 -0
  6. package/agents/researcher.md +25 -0
  7. package/agents/verifier.md +30 -0
  8. package/bin/dual-brain.mjs +2868 -0
  9. package/hooks/auto-update-wrapper.mjs +102 -0
  10. package/hooks/auto-update.sh +67 -0
  11. package/hooks/budget-balancer.mjs +679 -0
  12. package/hooks/control-panel.mjs +1195 -0
  13. package/hooks/cost-logger.mjs +286 -0
  14. package/hooks/cost-report.mjs +351 -0
  15. package/hooks/decision-ledger.mjs +299 -0
  16. package/hooks/dual-brain-review.mjs +404 -0
  17. package/hooks/dual-brain-think.mjs +393 -0
  18. package/hooks/enforce-tier.mjs +469 -0
  19. package/hooks/failure-detector.mjs +138 -0
  20. package/hooks/gpt-work-dispatcher.mjs +512 -0
  21. package/hooks/head-guard.mjs +105 -0
  22. package/hooks/health-check.mjs +444 -0
  23. package/hooks/install-git-hooks.mjs +106 -0
  24. package/hooks/model-registry.mjs +859 -0
  25. package/hooks/plan-generator.mjs +544 -0
  26. package/hooks/profiles.mjs +254 -0
  27. package/hooks/quality-gate.mjs +355 -0
  28. package/hooks/risk-classifier.mjs +41 -0
  29. package/hooks/session-report.mjs +514 -0
  30. package/hooks/setup-wizard.mjs +130 -0
  31. package/hooks/summary-checkpoint.mjs +432 -0
  32. package/hooks/task-classifier.mjs +328 -0
  33. package/hooks/test-orchestrator.mjs +1077 -0
  34. package/hooks/vibe-memory.mjs +463 -0
  35. package/hooks/vibe-router.mjs +387 -0
  36. package/hooks/wave-orchestrator.mjs +1397 -0
  37. package/install.mjs +1541 -0
  38. package/mcp-server/README.md +81 -0
  39. package/mcp-server/index.mjs +388 -0
  40. package/orchestrator.json +215 -0
  41. package/package.json +108 -0
  42. package/playbooks/debug.json +49 -0
  43. package/playbooks/refactor.json +57 -0
  44. package/playbooks/security-audit.json +57 -0
  45. package/playbooks/security.json +38 -0
  46. package/playbooks/test-gen.json +48 -0
  47. package/plugin.json +22 -0
  48. package/review-rules.md +17 -0
  49. package/shell-hook.sh +26 -0
  50. package/skills/go.md +22 -0
  51. package/skills/review.md +19 -0
  52. package/skills/status.md +13 -0
  53. package/skills/think.md +22 -0
  54. package/src/brief.mjs +266 -0
  55. package/src/decide.mjs +635 -0
  56. package/src/decompose.mjs +331 -0
  57. package/src/detect.mjs +345 -0
  58. package/src/dispatch.mjs +942 -0
  59. package/src/health.mjs +253 -0
  60. package/src/index.mjs +44 -0
  61. package/src/install-hooks.mjs +100 -0
  62. package/src/playbook.mjs +257 -0
  63. package/src/profile.mjs +990 -0
  64. package/src/redact.mjs +192 -0
  65. package/src/repo.mjs +292 -0
  66. package/src/session.mjs +1036 -0
  67. package/src/tui.mjs +197 -0
  68. package/src/update-check.mjs +35 -0
@@ -0,0 +1,990 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * profile.mjs — User profile module for the Dual-Brain Orchestrator.
4
+ *
5
+ * Exported API:
6
+ * loadProfile(cwd) → profile (or defaults)
7
+ * saveProfile(profile, opts) → write project or global file
8
+ * ensureProfile(cwd, opts) → load or onboard
9
+ * runOnboarding(opts) → interactive 3-question setup
10
+ * rememberPreference(text, opts) → add/update preference
11
+ * forgetPreference(text, cwd) → remove preference by fuzzy match
12
+ * getActivePreferences(cwd) → enabled global + project preferences
13
+ * getAvailableProviders(profile) → enabled providers with plan info
14
+ * isSoloBrain(profile) → true if only one provider enabled
15
+ * getHeadModel(profile) → suggested head model string
16
+ *
17
+ * CLI:
18
+ * node src/profile.mjs # show current profile
19
+ * node src/profile.mjs --init # run onboarding
20
+ * node src/profile.mjs --remember "…" # add preference
21
+ * node src/profile.mjs --forget "…" # remove preference
22
+ * node src/profile.mjs --providers # show available providers
23
+ */
24
+
25
+ import { createInterface } from 'readline';
26
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
27
+ import { homedir } from 'os';
28
+ import { join } from 'path';
29
+ import { execFile } from 'child_process';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Claude Code memory integration
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const MEMORY_FILE_NAME = 'dual_brain_preferences.md';
36
+ const MEMORY_INDEX_ENTRY =
37
+ '- [Dual-brain preferences](dual_brain_preferences.md) — Active routing preferences for model/provider selection';
38
+
39
+ /**
40
+ * Derive the Claude Code memory directory for the given project root.
41
+ * Returns null when the directory doesn't exist (i.e. not running on Replit).
42
+ */
43
+ function _memoryDir(cwd) {
44
+ const root = cwd || process.cwd();
45
+ // Replit persistent memory lives at a fixed path derived from the workspace root.
46
+ // Convert e.g. /home/runner/workspace → -home-runner-workspace
47
+ const encoded = root.replace(/\//g, '-');
48
+ const candidate = join(
49
+ root,
50
+ '.replit-tools',
51
+ '.claude-persistent',
52
+ 'projects',
53
+ encoded,
54
+ 'memory',
55
+ );
56
+ return existsSync(candidate) ? candidate : null;
57
+ }
58
+
59
+ /**
60
+ * Write (or update) the dual_brain_preferences.md file in the Claude Code
61
+ * memory directory, and ensure MEMORY.md has an index entry for it.
62
+ * Fails silently if the memory directory is absent or unwritable.
63
+ */
64
+ function syncPreferencesToMemory(profile, cwd) {
65
+ try {
66
+ const memDir = _memoryDir(cwd);
67
+ if (!memDir) return; // not on Replit / memory dir missing — skip silently
68
+
69
+ const prefs = (profile.preferences || []).filter(p => p.enabled);
70
+
71
+ // Build markdown body
72
+ const prefLines = prefs.length
73
+ ? prefs.map(p => `- ${p.text} (scope: ${p.scope || 'project'})`).join('\n')
74
+ : '_(no active preferences)_';
75
+
76
+ const content = [
77
+ '---',
78
+ 'name: dual-brain-preferences',
79
+ 'description: Active dual-brain routing preferences — affects model selection, provider choice, and dual-brain consensus',
80
+ 'metadata:',
81
+ ' type: project',
82
+ '---',
83
+ '',
84
+ 'Active dual-brain preferences:',
85
+ '',
86
+ prefLines,
87
+ '',
88
+ 'These preferences are enforced by the dual-brain orchestrator routing engine.',
89
+ 'Provider routing, model selection, and dual-brain consensus decisions',
90
+ 'respect these preferences automatically via src/decide.mjs.',
91
+ '',
92
+ ].join('\n');
93
+
94
+ const prefFile = join(memDir, MEMORY_FILE_NAME);
95
+ writeFileSync(prefFile, content, 'utf8');
96
+
97
+ // Update MEMORY.md index — add entry only if not already present
98
+ const indexFile = join(memDir, 'MEMORY.md');
99
+ if (existsSync(indexFile)) {
100
+ const existing = readFileSync(indexFile, 'utf8');
101
+ if (!existing.includes(MEMORY_FILE_NAME)) {
102
+ writeFileSync(indexFile, existing.trimEnd() + '\n' + MEMORY_INDEX_ENTRY + '\n', 'utf8');
103
+ }
104
+ }
105
+ } catch {
106
+ // Non-fatal — the profile JSON remains the source of truth
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Environment detection
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Detect the runtime environment.
116
+ * Returns { isReplit, hasReplitTools, isCI }.
117
+ */
118
+ function detectEnvironment() {
119
+ const isReplit = !!(process.env.REPL_ID || process.env.REPLIT_DB_URL);
120
+ const hasReplitTools = existsSync(join(process.cwd(), '.replit-tools'));
121
+ const isCI = !!(process.env.CI || process.env.GITHUB_ACTIONS);
122
+ return { isReplit, hasReplitTools, isCI };
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Auth detection
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Detect CLI login status for Claude and Codex.
131
+ * Checks config files on disk — never makes network calls.
132
+ *
133
+ * @returns {{ claude: AuthEntry, openai: AuthEntry }}
134
+ * @typedef {{ found: boolean, source: string|null, loginType: 'oauth'|'cli'|null }} AuthEntry
135
+ */
136
+ async function detectAuth() {
137
+ const results = {
138
+ claude: { found: false, source: null, loginType: null },
139
+ openai: { found: false, source: null, loginType: null },
140
+ };
141
+
142
+ // --- Claude: check .claude.json for oauthAccount (CLI login) ---
143
+ const claudePaths = [
144
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
145
+ join(homedir(), '.claude', '.claude.json'),
146
+ ];
147
+ for (const p of claudePaths) {
148
+ try {
149
+ const data = JSON.parse(readFileSync(p, 'utf8'));
150
+ if (data?.oauthAccount) {
151
+ results.claude.found = true;
152
+ results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
153
+ results.claude.loginType = 'oauth';
154
+ break;
155
+ }
156
+ // Legacy: apiKey field in .claude.json (set by claude CLI in some versions)
157
+ if (data?.apiKey && typeof data.apiKey === 'string') {
158
+ results.claude.found = true;
159
+ results.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
160
+ results.claude.loginType = 'cli';
161
+ break;
162
+ }
163
+ } catch { continue; }
164
+ }
165
+
166
+ // --- OpenAI/Codex: check auth.json for access_token or id_token (CLI login) ---
167
+ const codexPaths = [
168
+ '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
169
+ join(homedir(), '.codex', 'auth.json'),
170
+ ];
171
+ for (const p of codexPaths) {
172
+ try {
173
+ const data = JSON.parse(readFileSync(p, 'utf8'));
174
+ const accessToken = data?.tokens?.access_token || data?.access_token;
175
+ const idToken = data?.tokens?.id_token || data?.id_token;
176
+
177
+ if (accessToken || idToken) {
178
+ results.openai.found = true;
179
+ results.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
180
+ results.openai.loginType = 'oauth';
181
+ break;
182
+ }
183
+ } catch { continue; }
184
+ }
185
+
186
+ return results;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Subscription management (.dualbrain/profile.json)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Save subscription config for a provider into .dualbrain/profile.json.
195
+ * @param {string} provider — 'claude' or 'openai'
196
+ * @param {{ plan: string, label?: string, expiresAt?: string }} config
197
+ * @param {string} [cwd]
198
+ */
199
+ function saveSubscription(provider, config, cwd) {
200
+ const profile = loadProfile(cwd);
201
+ if (!profile.providers[provider]) profile.providers[provider] = { enabled: true };
202
+ profile.providers[provider].plan = config.plan;
203
+ profile.providers[provider].enabled = true;
204
+ if (config.label) profile.providers[provider].label = config.label;
205
+ if (config.expiresAt) profile.providers[provider].expiresAt = config.expiresAt;
206
+ saveProfile(profile, { cwd: cwd || process.cwd() });
207
+ return profile;
208
+ }
209
+
210
+ /**
211
+ * Return subscription configs for all providers from the saved profile.
212
+ * @param {string} [cwd]
213
+ * @returns {{ [provider: string]: { plan: string, enabled: boolean, label?: string, expiresAt?: string } }}
214
+ */
215
+ function listSubscriptions(cwd) {
216
+ const profile = loadProfile(cwd);
217
+ return profile.providers || {};
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Auto-detect subscription plans from provider config files
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Decode a JWT payload without verifying the signature.
226
+ * Returns the payload object, or null on failure.
227
+ * @param {string} token
228
+ */
229
+ function decodeJwtPayload(token) {
230
+ try {
231
+ const parts = token.split('.');
232
+ if (parts.length < 2) return null;
233
+ // Base64url → base64 → Buffer
234
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
235
+ const json = Buffer.from(b64, 'base64').toString('utf8');
236
+ return JSON.parse(json);
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Infer plan tier from Claude Code and Codex auth config files.
244
+ * Returns { claude: '$20'|'$100'|'$200'|null, openai: '$20'|'$100'|'$200'|null }.
245
+ * Returns nulls for any provider whose config cannot be read — never throws.
246
+ *
247
+ * NOTE: This reads rate-limit tier signals (organizationRateLimitTier for Claude,
248
+ * chatgpt_plan_type JWT claim for OpenAI) and maps them to price tiers.
249
+ * It does NOT retrieve the actual subscription plan name from the provider —
250
+ * labels like "Max x5" or "Pro" are our own interpretations of those signals.
251
+ */
252
+ function detectPlans() {
253
+ const plans = { claude: null, openai: null };
254
+
255
+ // --- Claude: read organizationRateLimitTier from .claude.json ---
256
+ const claudePaths = [
257
+ // Replit-tools persistent path (takes precedence)
258
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
259
+ join(homedir(), '.claude', '.claude.json'),
260
+ ];
261
+ for (const p of claudePaths) {
262
+ try {
263
+ const data = JSON.parse(readFileSync(p, 'utf8'));
264
+ const tier = data?.oauthAccount?.organizationRateLimitTier;
265
+ if (tier) {
266
+ if (tier.includes('max_20x')) plans.claude = '$200';
267
+ else if (tier.includes('max_5x')) plans.claude = '$100';
268
+ else plans.claude = '$20';
269
+ }
270
+ break;
271
+ } catch { continue; }
272
+ }
273
+
274
+ // --- OpenAI/Codex: read plan from auth.json (direct field or JWT payload) ---
275
+ const codexPaths = [
276
+ // Replit-tools persistent path (takes precedence)
277
+ '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
278
+ join(homedir(), '.codex', 'auth.json'),
279
+ ];
280
+ for (const p of codexPaths) {
281
+ try {
282
+ const data = JSON.parse(readFileSync(p, 'utf8'));
283
+
284
+ // Try a top-level `plan` field first
285
+ let planType = data.plan ?? null;
286
+
287
+ // Fall back to decoding the JWT id_token or access_token
288
+ if (!planType) {
289
+ for (const key of ['id_token', 'access_token']) {
290
+ const token = data?.tokens?.[key];
291
+ if (!token) continue;
292
+ const payload = decodeJwtPayload(token);
293
+ planType =
294
+ payload?.['https://api.openai.com/auth']?.chatgpt_plan_type ?? null;
295
+ if (planType) break;
296
+ }
297
+ }
298
+
299
+ if (planType) {
300
+ // pro / prolite → $100 | plus → $20 | pro200 / team → $200
301
+ if (planType === 'pro200' || planType === 'team') plans.openai = '$200';
302
+ else if (planType === 'pro' || planType === 'prolite') plans.openai = '$100';
303
+ else plans.openai = '$20';
304
+ }
305
+ break;
306
+ } catch { continue; }
307
+ }
308
+
309
+ return plans;
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Paths & defaults
314
+ // ---------------------------------------------------------------------------
315
+
316
+ const GLOBAL_DIR = join(homedir(), '.config', 'dual-brain');
317
+ const GLOBAL_PATH = join(GLOBAL_DIR, 'profile.json');
318
+ const projectPath = (cwd) => join(cwd || process.cwd(), '.dualbrain', 'profile.json');
319
+
320
+ function defaultProfile() {
321
+ const now = new Date().toISOString();
322
+ return {
323
+ schemaVersion: 1,
324
+ createdAt: now,
325
+ updatedAt: now,
326
+ providers: {
327
+ claude: { plan: '$20', enabled: true },
328
+ openai: { plan: '$20', enabled: false },
329
+ },
330
+ mode: 'auto',
331
+ bias: 'balanced',
332
+ preferences: [],
333
+ };
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Schema migration
338
+ // ---------------------------------------------------------------------------
339
+
340
+ function migrateProfile(profile) {
341
+ // v5.x compat: convert old `subscriptions` field to `providers`
342
+ if (profile.subscriptions && !profile.providers) {
343
+ profile.providers = {};
344
+ for (const [key, sub] of Object.entries(profile.subscriptions)) {
345
+ profile.providers[key] = {
346
+ plan: sub.plan || '$20',
347
+ enabled: true,
348
+ };
349
+ }
350
+ delete profile.subscriptions;
351
+ }
352
+
353
+ if (!profile.schemaVersion || profile.schemaVersion < 1) {
354
+ // v0 → v1: add missing fields with defaults
355
+ profile.schemaVersion = 1;
356
+ profile.mode = profile.mode || 'auto';
357
+ profile.bias = profile.bias || 'balanced';
358
+ profile.preferences = profile.preferences || [];
359
+ profile.providers = profile.providers || {};
360
+ }
361
+ // Future migrations go here:
362
+ // if (profile.schemaVersion < 2) { ... profile.schemaVersion = 2; }
363
+ return profile;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Load / save
368
+ // ---------------------------------------------------------------------------
369
+
370
+ function loadProfile(cwd) {
371
+ let profile;
372
+ for (const p of [projectPath(cwd), GLOBAL_PATH]) {
373
+ if (existsSync(p)) {
374
+ try { profile = migrateProfile(JSON.parse(readFileSync(p, 'utf8'))); break; } catch { /* skip */ }
375
+ }
376
+ }
377
+ if (!profile) profile = defaultProfile();
378
+
379
+ // Read plan tier from auth config files (JWT or organizationRateLimitTier) and
380
+ // apply if it differs from the stored profile value.
381
+ // NOTE: detectPlans() reads rate-limit tier data from the auth config — it infers
382
+ // a price tier ($20/$100/$200) from that signal, not from the subscription name itself.
383
+ // The plan label (e.g. "Max x5") comes from our own mapping, not from Claude/OpenAI.
384
+ const detected = detectPlans();
385
+ for (const [provider, detectedPlan] of Object.entries(detected)) {
386
+ if (!detectedPlan) continue;
387
+ if (!profile.providers[provider]) continue;
388
+ const stored = profile.providers[provider].plan;
389
+ if (stored !== detectedPlan) {
390
+ const providerName = provider === 'claude' ? 'Claude' : 'OpenAI';
391
+ process.stderr.write(`[dual-brain] ${providerName}: plan updated to ${detectedPlan} (from auth config)\n`);
392
+ profile.providers[provider].plan = detectedPlan;
393
+ }
394
+ }
395
+
396
+ return profile;
397
+ }
398
+
399
+ function saveProfile(profile, opts = {}) {
400
+ const target = opts.global ? GLOBAL_PATH : projectPath(opts.cwd);
401
+ const dir = target.slice(0, target.lastIndexOf('/'));
402
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
403
+ profile.updatedAt = new Date().toISOString();
404
+ const tmp = target + '.tmp.' + process.pid;
405
+ writeFileSync(tmp, JSON.stringify(profile, null, 2) + '\n');
406
+ renameSync(tmp, target);
407
+ return target;
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Onboarding
412
+ // ---------------------------------------------------------------------------
413
+
414
+ async function runOnboarding(opts = {}) {
415
+ if (!opts.interactive) return defaultProfile();
416
+
417
+ // Accept an externally-provided readline instance (shared with REPL/auth setup)
418
+ // or create one internally if not provided. Only close if we created it.
419
+ const rlProvided = !!opts.rl;
420
+ const rl = opts.rl || createInterface({ input: process.stdin, output: process.stdout });
421
+ const ask = (q) => new Promise(res => rl.question(q, res));
422
+ const profile = defaultProfile();
423
+
424
+ try {
425
+ process.stdout.write('\nDual-Brain Orchestrator — First-time setup\n\n');
426
+
427
+ const q1 = (await ask('Which AI subscriptions do you have?\n (1) Claude only (2) OpenAI only (3) Both\n> ')).trim();
428
+ if (q1 === '2') { profile.providers.claude.enabled = false; profile.providers.openai.enabled = true; }
429
+ else if (q1 === '3') { profile.providers.openai.enabled = true; }
430
+
431
+ const PLANS = { '1': '$20', '2': '$100', '3': '$200' };
432
+ for (const [key, prov] of Object.entries(profile.providers)) {
433
+ if (!prov.enabled) continue;
434
+ const label = key === 'claude' ? 'Claude' : 'OpenAI/ChatGPT';
435
+ const q2 = (await ask(`\n${label} tier?\n (1) $20/mo (2) $100/mo (3) $200/mo\n> `)).trim();
436
+ prov.plan = PLANS[q2] || '$20';
437
+ }
438
+
439
+ const q3 = (await ask('\nDefault optimization?\n (1) Save usage (2) Balanced (3) Best quality\n> ')).trim();
440
+ profile.bias = ({ '1': 'cost-saver', '3': 'quality-first' })[q3] || 'balanced';
441
+
442
+ const n = Object.values(profile.providers).filter(p => p.enabled).length;
443
+ profile.mode = n >= 2 ? 'dual' : profile.providers.claude.enabled ? 'solo-claude' : 'solo-openai';
444
+ process.stdout.write('\nProfile saved.\n');
445
+ } finally {
446
+ // Only close if we created the rl instance (not if it was passed in)
447
+ if (!rlProvided) rl.close();
448
+ }
449
+ return profile;
450
+ }
451
+
452
+ async function ensureProfile(cwd, opts = {}) {
453
+ for (const p of [projectPath(cwd), GLOBAL_PATH]) {
454
+ if (existsSync(p)) {
455
+ try { return migrateProfile(JSON.parse(readFileSync(p, 'utf8'))); } catch { /* skip */ }
456
+ }
457
+ }
458
+ const profile = await runOnboarding(opts);
459
+ saveProfile(profile, { cwd, global: opts.global });
460
+ return profile;
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Preferences
465
+ // ---------------------------------------------------------------------------
466
+
467
+ const VALID_SCOPES = ['one-off', 'project', 'global'];
468
+
469
+ function rememberPreference(text, opts = {}) {
470
+ const scope = VALID_SCOPES.includes(opts.scope) ? opts.scope : 'project';
471
+ const cwd = opts.cwd || process.cwd();
472
+ const profile = loadProfile(cwd);
473
+ const needle = text.toLowerCase();
474
+ const idx = profile.preferences.findIndex(p =>
475
+ p.text.toLowerCase().includes(needle) || needle.includes(p.text.toLowerCase()));
476
+ if (idx >= 0) profile.preferences[idx] = { text, enabled: true, scope };
477
+ else profile.preferences.push({ text, enabled: true, scope });
478
+ saveProfile(profile, { cwd, global: opts.global || scope === 'global' });
479
+ syncPreferencesToMemory(profile, cwd);
480
+ return profile;
481
+ }
482
+
483
+ function forgetPreference(text, cwd) {
484
+ const profile = loadProfile(cwd);
485
+ const needle = text.toLowerCase();
486
+ profile.preferences = profile.preferences.filter(p => !p.text.toLowerCase().includes(needle));
487
+ saveProfile(profile, { cwd });
488
+ syncPreferencesToMemory(profile, cwd);
489
+ return profile;
490
+ }
491
+
492
+ function getActivePreferences(cwd) {
493
+ const seen = new Set();
494
+ const result = [];
495
+ for (const p of [GLOBAL_PATH, projectPath(cwd)]) {
496
+ if (!existsSync(p)) continue;
497
+ try {
498
+ for (const pref of JSON.parse(readFileSync(p, 'utf8')).preferences || []) {
499
+ if (pref.enabled && !seen.has(pref.text)) { seen.add(pref.text); result.push(pref); }
500
+ }
501
+ } catch { /* skip */ }
502
+ }
503
+ return result;
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Provider helpers
508
+ // ---------------------------------------------------------------------------
509
+
510
+ const PLAN_RANK = { '$20': 1, '$100': 2, '$200': 3 };
511
+
512
+ function getAvailableProviders(profile) {
513
+ return Object.entries(profile.providers || {})
514
+ .filter(([, p]) => p.enabled)
515
+ .map(([name, p]) => ({ name, plan: p.plan, rank: PLAN_RANK[p.plan] || 1 }));
516
+ }
517
+
518
+ function isSoloBrain(profile) {
519
+ return getAvailableProviders(profile).length === 1;
520
+ }
521
+
522
+ function getHeadModel(profile) {
523
+ const providers = getAvailableProviders(profile);
524
+ if (providers.length === 0) return 'sonnet';
525
+ if (providers.length === 1) return providers[0].name === 'openai' ? 'gpt-4o' : 'sonnet';
526
+ const top = providers.reduce((a, b) => (b.rank > a.rank ? b : a));
527
+ return top.name === 'openai' ? 'gpt-4o' : 'sonnet';
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // CLI
532
+ // ---------------------------------------------------------------------------
533
+
534
+ async function main() {
535
+ const args = process.argv.slice(2);
536
+ const cwd = process.cwd();
537
+ const flag = args[0];
538
+ const val = args[1];
539
+
540
+ if (flag === '--init') {
541
+ const profile = await runOnboarding({ interactive: true });
542
+ saveProfile(profile, { cwd });
543
+ return;
544
+ }
545
+ if (flag === '--remember') {
546
+ if (!val) { process.stderr.write('Usage: --remember "text"\n'); process.exit(1); }
547
+ const p = rememberPreference(val, { cwd });
548
+ process.stdout.write(`Preference saved. Total: ${p.preferences.length}\n`);
549
+ return;
550
+ }
551
+ if (flag === '--forget') {
552
+ if (!val) { process.stderr.write('Usage: --forget "text"\n'); process.exit(1); }
553
+ forgetPreference(val, cwd);
554
+ process.stdout.write('Preference removed (if matched).\n');
555
+ return;
556
+ }
557
+ if (flag === '--providers') {
558
+ const providers = getAvailableProviders(loadProfile(cwd));
559
+ if (!providers.length) { process.stdout.write('No providers enabled.\n'); return; }
560
+ providers.forEach(p => process.stdout.write(`${p.name} plan=${p.plan}\n`));
561
+ return;
562
+ }
563
+
564
+ // default: show profile
565
+ const profile = loadProfile(cwd);
566
+ const providers = getAvailableProviders(profile);
567
+ [
568
+ `mode : ${profile.mode}`,
569
+ `bias : ${profile.bias}`,
570
+ `head model : ${getHeadModel(profile)}`,
571
+ `providers : ${providers.map(p => `${p.name} (${p.plan})`).join(', ') || 'none'}`,
572
+ `prefs : ${profile.preferences?.filter(p => p.enabled).length || 0} active`,
573
+ ].forEach(l => process.stdout.write(l + '\n'));
574
+ }
575
+
576
+ const isMain = process.argv[1]?.endsWith('profile.mjs');
577
+ if (isMain) main().catch(e => { process.stderr.write(e.message + '\n'); process.exit(1); });
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Exports
581
+ // ---------------------------------------------------------------------------
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Auto-setup (1-click, no user input required)
585
+ // ---------------------------------------------------------------------------
586
+
587
+ /**
588
+ * Attempt to configure a profile entirely from detected state — no user input.
589
+ *
590
+ * Returns:
591
+ * {
592
+ * confident: boolean, // true when at least one provider was found
593
+ * profile: object|null, // fully-built profile ready to save, or null
594
+ * warnings: string[], // non-fatal issues (e.g. missing provider)
595
+ * actions: string[], // human-readable lines for the summary box
596
+ * }
597
+ *
598
+ * IMPORTANT: this function NEVER stores credentials — it only reads what's
599
+ * already present on disk / in environment variables.
600
+ */
601
+ async function autoSetup(cwd) {
602
+ const env = detectEnvironment();
603
+ const auth = await detectAuth();
604
+ const plans = detectPlans();
605
+
606
+ const result = {
607
+ confident: false,
608
+ profile: null,
609
+ warnings: [],
610
+ actions: [],
611
+ };
612
+
613
+ // Need at least one provider authenticated
614
+ if (!auth.claude.found && !auth.openai.found) {
615
+ result.warnings.push('No provider credentials found');
616
+ return result;
617
+ }
618
+
619
+ // Build profile from detected state
620
+ const profile = defaultProfile();
621
+
622
+ // Claude
623
+ if (auth.claude.found) {
624
+ profile.providers.claude.enabled = true;
625
+ profile.providers.claude.plan = plans.claude || '$20';
626
+ // Plan tier is inferred from auth config signal — show tier with "configured",
627
+ // not a plan name we didn't actually detect.
628
+ const claudeTierLabel = plans.claude ? `${plans.claude} configured` : 'connected';
629
+ result.actions.push(`Claude: ${claudeTierLabel} (${auth.claude.source})`);
630
+ } else {
631
+ profile.providers.claude.enabled = false;
632
+ result.warnings.push('Claude CLI not logged in — run: claude login');
633
+ }
634
+
635
+ // OpenAI
636
+ if (auth.openai.found) {
637
+ profile.providers.openai.enabled = true;
638
+ profile.providers.openai.plan = plans.openai || '$20';
639
+ // Plan tier is inferred from JWT claim in auth config — show tier with "configured",
640
+ // not a plan name we didn't actually detect.
641
+ const openaiTierLabel = plans.openai ? `${plans.openai} configured` : 'connected';
642
+ result.actions.push(`OpenAI: ${openaiTierLabel} (${auth.openai.source})`);
643
+ } else {
644
+ profile.providers.openai.enabled = false;
645
+ result.warnings.push('Codex CLI not logged in — run: codex login');
646
+ }
647
+
648
+ // Mode
649
+ const enabledCount = [auth.claude.found, auth.openai.found].filter(Boolean).length;
650
+ profile.mode = enabledCount >= 2 ? 'dual'
651
+ : auth.claude.found ? 'solo-claude'
652
+ : 'solo-openai';
653
+ profile.bias = 'balanced';
654
+
655
+ // Environment note
656
+ if (env.isReplit && env.hasReplitTools) {
657
+ result.actions.push('Replit + replit-tools detected');
658
+ } else if (env.isReplit) {
659
+ result.actions.push('Replit environment detected');
660
+ }
661
+
662
+ result.confident = true;
663
+ result.profile = profile;
664
+ return result;
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // OAuth token auto-refresh
669
+ // ---------------------------------------------------------------------------
670
+
671
+ /**
672
+ * Silently refresh the Claude OAuth token before it expires.
673
+ * Mirrors the approach used by replit-tools/data-tools claude-auth-refresh.sh,
674
+ * but implemented in JavaScript.
675
+ *
676
+ * Returns one of:
677
+ * { status: 'valid', hoursRemaining }
678
+ * { status: 'refreshed', hoursRemaining }
679
+ * { status: 'expiring_no_refresh' | 'expired', hoursRemaining }
680
+ * { status: 'no_credentials' | 'parse_error' | 'no_expiry' }
681
+ * { status: 'refresh_failed', error }
682
+ *
683
+ * @param {string} [cwd]
684
+ */
685
+ async function autoRefreshToken(cwd) {
686
+ const home = process.env.HOME || '/root';
687
+ const credPaths = [
688
+ join(home, '.claude', '.credentials.json'),
689
+ join(cwd || '.', '.replit-tools', '.claude-persistent', '.credentials.json'),
690
+ ];
691
+
692
+ let credPath = null;
693
+ for (const p of credPaths) {
694
+ if (existsSync(p)) { credPath = p; break; }
695
+ }
696
+ if (!credPath) return { status: 'no_credentials' };
697
+
698
+ let creds;
699
+ try {
700
+ creds = JSON.parse(readFileSync(credPath, 'utf8'));
701
+ } catch { return { status: 'parse_error' }; }
702
+
703
+ const oauth = creds?.claudeAiOauth;
704
+ if (!oauth?.expiresAt) return { status: 'no_expiry' };
705
+
706
+ const now = Date.now();
707
+ const remainingMs = oauth.expiresAt - now;
708
+ const remainingHours = Math.floor(remainingMs / 1000 / 60 / 60);
709
+
710
+ // More than 2 hours left — no refresh needed
711
+ if (remainingHours >= 2) {
712
+ return { status: 'valid', hoursRemaining: remainingHours };
713
+ }
714
+
715
+ // Need refresh
716
+ if (!oauth.refreshToken) {
717
+ return { status: remainingMs > 0 ? 'expiring_no_refresh' : 'expired', hoursRemaining: remainingHours };
718
+ }
719
+
720
+ try {
721
+ const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
722
+ method: 'POST',
723
+ headers: { 'Content-Type': 'application/json' },
724
+ body: JSON.stringify({
725
+ grant_type: 'refresh_token',
726
+ refresh_token: oauth.refreshToken,
727
+ client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
728
+ }),
729
+ });
730
+
731
+ if (!res.ok) return { status: 'refresh_failed', error: `HTTP ${res.status}` };
732
+
733
+ const data = await res.json();
734
+ if (!data.access_token) return { status: 'refresh_failed', error: 'no access_token' };
735
+
736
+ // Update credentials
737
+ const newExpiresAt = now + (data.expires_in * 1000);
738
+ creds.claudeAiOauth.accessToken = data.access_token;
739
+ if (data.refresh_token) creds.claudeAiOauth.refreshToken = data.refresh_token;
740
+ creds.claudeAiOauth.expiresAt = newExpiresAt;
741
+
742
+ // Backup then write
743
+ try { writeFileSync(credPath + '.backup', readFileSync(credPath)); } catch {}
744
+ writeFileSync(credPath, JSON.stringify(creds));
745
+
746
+ const newHours = Math.floor((data.expires_in) / 60 / 60);
747
+ return { status: 'refreshed', hoursRemaining: newHours };
748
+ } catch (e) {
749
+ return { status: 'refresh_failed', error: e.message };
750
+ }
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // detectExistingAuth — silent onboarding scan
755
+ // ---------------------------------------------------------------------------
756
+
757
+ /**
758
+ * Run a CLI command with a timeout, returning stdout as a string.
759
+ * Resolves with null on timeout, error, or non-zero exit.
760
+ * @param {string} cmd
761
+ * @param {string[]} args
762
+ * @param {number} timeoutMs
763
+ * @returns {Promise<string|null>}
764
+ */
765
+ function _runWithTimeout(cmd, args, timeoutMs) {
766
+ return new Promise(resolve => {
767
+ let settled = false;
768
+ const done = (val) => { if (!settled) { settled = true; resolve(val); } };
769
+
770
+ let child;
771
+ try {
772
+ child = execFile(cmd, args, { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
773
+ done(err ? null : (stdout || '').trim());
774
+ });
775
+ } catch {
776
+ done(null);
777
+ return;
778
+ }
779
+
780
+ // Belt-and-suspenders timeout fallback
781
+ const timer = setTimeout(() => {
782
+ try { child.kill('SIGTERM'); } catch {}
783
+ done(null);
784
+ }, timeoutMs + 500);
785
+
786
+ if (child?.on) {
787
+ child.on('close', () => clearTimeout(timer));
788
+ }
789
+ });
790
+ }
791
+
792
+ /**
793
+ * Derive a human-readable plan label from a plan tier string.
794
+ * @param {'claude'|'openai'} provider
795
+ * @param {string} plan e.g. '$20' | '$100' | '$200'
796
+ */
797
+ function _planLabel(provider, plan) {
798
+ const labels = {
799
+ claude: { '$20': 'Claude Pro ($20)', '$100': 'Claude Max x5 ($100)', '$200': 'Claude Max x20 ($200)' },
800
+ openai: { '$20': 'ChatGPT Plus ($20)', '$100': 'ChatGPT Pro ($100)', '$200': 'ChatGPT Pro ($200)' },
801
+ };
802
+ return labels[provider]?.[plan] ?? `${provider} ${plan}`;
803
+ }
804
+
805
+ /**
806
+ * Silently scan for existing auth from all known sources and return what was
807
+ * found, together with smart setup recommendations.
808
+ *
809
+ * Checks (in order, all non-throwing):
810
+ * 1. data-tools / replit-tools — ~/.claude/credentials.json or
811
+ * .replit-tools/.claude-persistent/.credentials.json for a session key
812
+ * 2. Claude CLI — `claude auth status` with 3 s timeout
813
+ * 3. Codex CLI — `codex auth status` with 3 s timeout or
814
+ * ~/.codex/ config files
815
+ * 4. Existing dual-brain config — .dualbrain/profile.json
816
+ *
817
+ * Returns:
818
+ * {
819
+ * claude: { found: boolean, source: string|null, plan: string|null, expiresAt: string|null },
820
+ * openai: { found: boolean, source: string|null, plan: string|null },
821
+ * existingProfile: boolean,
822
+ * recommendations: { headModel: string, budget: string, profile: string },
823
+ * }
824
+ *
825
+ * @param {string} [cwd]
826
+ */
827
+ async function detectExistingAuth(cwd) {
828
+ const home = homedir();
829
+ const root = cwd || process.cwd();
830
+
831
+ // -------------------------------------------------------------------------
832
+ // Result skeleton
833
+ // -------------------------------------------------------------------------
834
+ const result = {
835
+ claude: { found: false, source: null, plan: null, expiresAt: null },
836
+ openai: { found: false, source: null, plan: null },
837
+ existingProfile: false,
838
+ recommendations: { headModel: 'claude-sonnet-4-6', budget: '$20', profile: 'balanced' },
839
+ };
840
+
841
+ // -------------------------------------------------------------------------
842
+ // 1. data-tools / replit-tools — credentials.json session key
843
+ // -------------------------------------------------------------------------
844
+ const credPaths = [
845
+ join(root, '.replit-tools', '.claude-persistent', '.credentials.json'),
846
+ join(home, '.claude', '.credentials.json'),
847
+ // legacy replit persistent path
848
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.credentials.json',
849
+ ];
850
+ for (const credPath of credPaths) {
851
+ try {
852
+ const creds = JSON.parse(readFileSync(credPath, 'utf8'));
853
+ const oauth = creds?.claudeAiOauth;
854
+ if (oauth?.accessToken || oauth?.sessionKey) {
855
+ result.claude.found = true;
856
+ result.claude.source = credPath.includes('.replit-tools') ? 'data-tools' : 'credentials.json';
857
+ // Expiry
858
+ if (oauth.expiresAt) {
859
+ try { result.claude.expiresAt = new Date(oauth.expiresAt).toISOString(); } catch {}
860
+ }
861
+ break;
862
+ }
863
+ } catch { /* non-fatal */ }
864
+ }
865
+
866
+ // -------------------------------------------------------------------------
867
+ // 2. Claude CLI auth detection (config files + `claude auth status`)
868
+ // -------------------------------------------------------------------------
869
+ if (!result.claude.found) {
870
+ // Config-file scan (same paths as detectAuth)
871
+ const claudeConfigPaths = [
872
+ join(root, '.replit-tools', '.claude-persistent', '.claude.json'),
873
+ '/home/runner/workspace/.replit-tools/.claude-persistent/.claude.json',
874
+ join(home, '.claude', '.claude.json'),
875
+ ];
876
+ for (const p of claudeConfigPaths) {
877
+ try {
878
+ const data = JSON.parse(readFileSync(p, 'utf8'));
879
+ if (data?.oauthAccount || (data?.apiKey && typeof data.apiKey === 'string')) {
880
+ result.claude.found = true;
881
+ result.claude.source = p.includes('.replit-tools') ? 'claude CLI (replit-tools)' : 'claude CLI';
882
+ break;
883
+ }
884
+ } catch { /* non-fatal */ }
885
+ }
886
+
887
+ // CLI fallback: `claude auth status`
888
+ if (!result.claude.found) {
889
+ const out = await _runWithTimeout('claude', ['auth', 'status'], 3000);
890
+ if (out && /logged.in|authenticated|signed.in/i.test(out)) {
891
+ result.claude.found = true;
892
+ result.claude.source = 'claude CLI (auth status)';
893
+ }
894
+ }
895
+ }
896
+
897
+ // -------------------------------------------------------------------------
898
+ // 3. Codex CLI / OpenAI auth detection
899
+ // -------------------------------------------------------------------------
900
+ const codexConfigPaths = [
901
+ join(root, '.replit-tools', '.codex-persistent', 'auth.json'),
902
+ '/home/runner/workspace/.replit-tools/.codex-persistent/auth.json',
903
+ join(home, '.codex', 'auth.json'),
904
+ ];
905
+ for (const p of codexConfigPaths) {
906
+ try {
907
+ const data = JSON.parse(readFileSync(p, 'utf8'));
908
+ const accessToken = data?.tokens?.access_token || data?.access_token;
909
+ const idToken = data?.tokens?.id_token || data?.id_token;
910
+ if (accessToken || idToken) {
911
+ result.openai.found = true;
912
+ result.openai.source = p.includes('.replit-tools') ? 'codex CLI (replit-tools)' : 'codex CLI';
913
+ break;
914
+ }
915
+ } catch { /* non-fatal */ }
916
+ }
917
+
918
+ // CLI fallback: `codex auth status`
919
+ if (!result.openai.found) {
920
+ const out = await _runWithTimeout('codex', ['auth', 'status'], 3000);
921
+ if (out && /logged.in|authenticated|signed.in/i.test(out)) {
922
+ result.openai.found = true;
923
+ result.openai.source = 'codex CLI (auth status)';
924
+ }
925
+ }
926
+
927
+ // -------------------------------------------------------------------------
928
+ // 4. Existing dual-brain profile
929
+ // -------------------------------------------------------------------------
930
+ for (const p of [projectPath(root), GLOBAL_PATH]) {
931
+ if (existsSync(p)) {
932
+ result.existingProfile = true;
933
+ break;
934
+ }
935
+ }
936
+
937
+ // -------------------------------------------------------------------------
938
+ // Plan tier inference (re-uses detectPlans which reads auth config files)
939
+ // NOTE: This is NOT subscription detection — we infer a price tier ($20/$100/$200)
940
+ // from rate-limit tier signals in the auth config (organizationRateLimitTier for
941
+ // Claude, JWT chatgpt_plan_type for OpenAI). The CLI does not report the actual
942
+ // plan name or price. Any plan label shown to the user comes from our own mapping.
943
+ // -------------------------------------------------------------------------
944
+ const plans = detectPlans();
945
+ if (result.claude.found && plans.claude) result.claude.plan = plans.claude;
946
+ if (result.openai.found && plans.openai) result.openai.plan = plans.openai;
947
+
948
+ // -------------------------------------------------------------------------
949
+ // Smart recommendations
950
+ // -------------------------------------------------------------------------
951
+ const claudeRank = PLAN_RANK[result.claude.plan] || 0;
952
+ const openaiRank = PLAN_RANK[result.openai.plan] || 0;
953
+
954
+ if (result.claude.found && !result.openai.found) {
955
+ // Solo Claude
956
+ result.recommendations.headModel = 'claude-sonnet-4-6';
957
+ result.recommendations.budget = result.claude.plan || '$20';
958
+ result.recommendations.profile = claudeRank >= 2 ? 'quality-first' : 'balanced';
959
+ } else if (result.openai.found && !result.claude.found) {
960
+ // Solo OpenAI
961
+ result.recommendations.headModel = 'gpt-4o';
962
+ result.recommendations.budget = result.openai.plan || '$20';
963
+ result.recommendations.profile = openaiRank >= 2 ? 'quality-first' : 'balanced';
964
+ } else if (result.claude.found && result.openai.found) {
965
+ // Both available — higher-ranked provider drives HEAD model
966
+ if (openaiRank > claudeRank) {
967
+ result.recommendations.headModel = 'gpt-4o';
968
+ } else {
969
+ result.recommendations.headModel = 'claude-sonnet-4-6';
970
+ }
971
+ const topPlan = openaiRank >= claudeRank ? result.openai.plan : result.claude.plan;
972
+ result.recommendations.budget = topPlan || '$20';
973
+ const topRank = Math.max(claudeRank, openaiRank);
974
+ result.recommendations.profile = topRank >= 2 ? 'quality-first' : 'balanced';
975
+ }
976
+ // else: no auth found — defaults remain (claude-sonnet-4-6 / $20 / balanced)
977
+
978
+ return result;
979
+ }
980
+
981
+ export {
982
+ loadProfile, saveProfile, ensureProfile, runOnboarding,
983
+ rememberPreference, forgetPreference, getActivePreferences,
984
+ getAvailableProviders, isSoloBrain, getHeadModel,
985
+ detectPlans, syncPreferencesToMemory,
986
+ detectAuth, detectEnvironment,
987
+ saveSubscription, listSubscriptions,
988
+ defaultProfile, autoSetup, autoRefreshToken,
989
+ detectExistingAuth,
990
+ };