dual-brain 4.6.0 → 4.7.1

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/install.mjs CHANGED
@@ -4,18 +4,22 @@
4
4
  *
5
5
  * Usage:
6
6
  * npx -y dual-brain # auto-detect, configure, done
7
+ * npx -y dual-brain update # refresh hooks to latest package version
7
8
  * npx dual-brain --force # overwrite existing config
8
9
  * npx dual-brain --dry-run # detect only, don't install
9
10
  * npx dual-brain --help
10
11
  */
11
- import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'fs';
12
- import https from 'https';
12
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
13
+ import { createInterface } from 'readline';
13
14
  import { dirname, join, resolve } from 'path';
14
15
  import { fileURLToPath } from 'url';
15
16
  import { spawnSync } from 'child_process';
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  const VERSION = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version;
20
+ const VERSION_STAMP_FILE = '.claude/dual-brain.version.json';
21
+ const UPDATE_CACHE_FILE = '.claude/dual-brain.update-check.json';
22
+ const UPDATE_CACHE_TTL_MS = 60 * 60 * 1000;
19
23
 
20
24
  // ─── Replit Detection ──────────────────────────────────────────────────────
21
25
 
@@ -30,14 +34,9 @@ const flag = (f) => argv.includes(f);
30
34
  const force = flag('--force');
31
35
  const dryRun = flag('--dry-run');
32
36
  const jsonOut = flag('--json');
33
- const yesFlag = flag('--yes') || flag('-y');
34
- const noLaunch = flag('--no-launch');
35
37
  const positional = argv.filter(a => !a.startsWith('-'));
36
38
  const subcommand = positional[0] || null;
37
39
 
38
- const BRAND = `Data Tools — Dual Brain v${VERSION}`;
39
- const BRAND_SUBTITLE = 'Built on replit-tools by Steve Moraco';
40
-
41
40
  if (flag('--version') || flag('-v')) {
42
41
  console.log(`dual-brain v${VERSION}`);
43
42
  process.exit(0);
@@ -45,107 +44,47 @@ if (flag('--version') || flag('-v')) {
45
44
 
46
45
  if (flag('--help') || flag('-h')) {
47
46
  console.log(`
48
- ${BRAND}
49
- ${BRAND_SUBTITLE}
47
+ 🧠 dual-brain v${VERSION} — Dual-provider orchestrator for Claude Code
50
48
 
51
49
  Usage: npx -y dual-brain [command] [options]
52
50
 
53
- Setup:
54
- (none) Start persistent chat (setup if needed)
51
+ ⌨️ Commands:
52
+ (none) 🧠 Auto-detect and install/update orchestrator
53
+ status 🟢 Open live control panel
54
+ auth 🔑 Show, login, or refresh provider auth
55
+ mode 🎛️ Show or switch profile
56
+ budget 💵 Set session/daily spend limits
57
+ explain 🧭 Explain last routing decision
58
+ update 🔄 Force re-install of latest hooks
55
59
  init Alias for default install
56
- start Start persistent chat
57
- chat Alias for start
58
- auth Check and repair provider authentication
59
- setup Configure providers, hooks, and preferences
60
- doctor Check system health and report issues
61
- reset Clear all state files (keeps config/hooks)
62
- repair Fix corrupt files, stale locks, re-register hooks
63
- --uninstall Remove dual-brain hooks and state files
64
-
65
- Status:
66
- status Open live control panel
67
- health Verify system health
68
- budget Show or set session/daily spend limits
69
- cost Activity and cost estimates
70
- report Generate session report
71
-
72
- Routing:
73
- mode Show or switch profile
74
- explain Explain last routing decision
75
- gate Run quality gate
76
- ledger Routing outcome insights
77
-
78
- GPT:
79
- think Dual-brain think (--question "..." [--round 2])
80
- review Dual-brain code review
81
- dispatch Dispatch work to GPT via Codex CLI
82
-
83
- Vibe:
84
- vibe Decompose casual requests into structured work
85
- plan Generate execution plans (--utterance "...")
86
- memory Persistent preferences and work threads
87
-
88
- Agents:
89
- agent Run a specialist agent template (explorer, security-review, test-writer, bug-hunter)
90
- agents List all available agent templates
91
- chain Run a built-in agent chain (explore-then-fix, review-and-test, audit-and-plan)
92
- chains List all available agent chains
93
-
94
- Ship Captain:
95
- do "<goal>" Run full pipeline: agents → tests → gate → PR
96
- --yolo Skip all confirmations
97
- --careful Confirm every step
98
- --plan-only Show plan without executing
99
- --no-pr Skip PR creation
100
- ship Create branch, run tests, open PR
101
- test-run Discover and run project tests
102
- diff Show current changes summary
103
- runs List recent Ship Captain runs
104
- resume Resume last incomplete run
105
-
106
- Dev:
107
- test Run self-tests
108
60
 
109
61
  Options:
110
62
  --force Overwrite all existing config
111
63
  --dry-run Detect environment only
112
64
  --json Output detection as JSON
113
- --no-launch Install/setup only, do not open Claude
114
65
  --help Show this help
115
66
 
116
- Routing modes:
117
- Auto (default) Adapts routing based on risk, health, outcomes
118
- Balanced Auto-routes, uses both providers evenly
119
- Conservative Fewer GPT dispatches, sticks to Claude
120
- Aggressive Maximizes both subscriptions, dual-brain for medium+
67
+ 🎛️ Routing modes:
68
+ 🤖 Auto (default) Adapts routing based on risk, health, outcomes
69
+ ⚖️ Balanced Auto-routes, uses both providers evenly
70
+ 🛡️ Conservative Fewer GPT dispatches, sticks to Claude
71
+ 🚀 Aggressive Maximizes both subscriptions, dual-brain for medium+
121
72
 
122
- Examples:
123
- ${cmd('npx dual-brain')} # quick start
124
- ${cmd('npx dual-brain setup')} # configure providers and hooks
73
+ 🚀 Examples:
74
+ ${cmd('npx dual-brain')} # install or update
125
75
  ${cmd('npx dual-brain status')} # open control panel
76
+ ${cmd('npx dual-brain auth')} # show Claude/Codex auth
77
+ ${cmd('npx dual-brain auth login')} # sign in missing providers
78
+ ${cmd('npx dual-brain auth refresh')} # refresh provider auth
126
79
  ${cmd('npx dual-brain mode cost-saver')} # switch profile
127
80
  ${cmd('npx dual-brain budget 8 25')} # \$8 session / \$25 daily
128
- ${cmd('npx dual-brain think --question "should we use Redis?"')}
129
- ${cmd('npx dual-brain vibe "fix login and update nav"')}
130
- ${cmd('npx dual-brain agents')} # list agent templates
131
- ${cmd('npx dual-brain agent explorer --question "where is auth handled?"')}
132
- ${cmd('npx dual-brain agent security-review --scope src/auth --severity high')}
133
- ${cmd('npx dual-brain chains')} # list available chains
134
- ${cmd('npx dual-brain chain explore-then-fix --question "auth bug" --scope "src/auth"')}
81
+ ${cmd('npx dual-brain explain')} # last routing decision
82
+ ${cmd('npx dual-brain update')} # refresh installed hooks
135
83
  `);
136
84
  process.exit(0);
137
85
  }
138
86
 
139
- const SUBCOMMANDS = [
140
- 'init', 'setup', 'status', 'mode', 'budget', 'explain',
141
- 'review', 'think', 'health', 'report', 'gate',
142
- 'vibe', 'plan', 'cost', 'dispatch', 'memory',
143
- 'test', 'ledger', 'doctor', 'reset', 'repair',
144
- 'auth', 'start', 'chat',
145
- 'chain', 'chains',
146
- 'agent', 'agents',
147
- 'do', 'ship', 'test-run', 'diff', 'runs', 'resume',
148
- ];
87
+ const SUBCOMMANDS = ['init', 'status', 'auth', 'mode', 'budget', 'explain', 'update'];
149
88
  if (subcommand && !SUBCOMMANDS.includes(subcommand)) {
150
89
  console.error(` Unknown command: ${subcommand}`);
151
90
  console.error(` Run: ${cmd('npx dual-brain --help')}`);
@@ -174,54 +113,18 @@ function run(cmd, args, opts = {}) {
174
113
  });
175
114
  }
176
115
 
177
- function detectReplit() {
178
- const isReplit = !!(process.env.REPL_ID || process.env.REPL_SLUG);
179
- const hasReplitTools = existsSync(resolve(process.cwd(), '.replit-tools'));
180
- return { isReplit, hasReplitTools };
181
- }
182
-
183
- // ─── Session Preferences + Auth Helpers ────────────────────────────────────
184
-
185
- const SESSION_PREFS_REL = '.claude/dual-brain.session.json';
186
- const DEFAULT_SESSION_PREFS = {
187
- head_model: 'sonnet',
188
- effort: 'medium',
189
- };
190
-
191
- function writeAtomicJson(targetPath, data) {
192
- const tmpPath = `${targetPath}.tmp.${process.pid}`;
193
- writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n');
194
- renameSync(tmpPath, targetPath);
195
- }
196
-
197
- function loadSessionPrefs(workspace) {
198
- const prefsPath = join(workspace, SESSION_PREFS_REL);
199
- try {
200
- const raw = JSON.parse(readFileSync(prefsPath, 'utf8'));
201
- return { ...DEFAULT_SESSION_PREFS, ...raw };
202
- } catch {
203
- return { ...DEFAULT_SESSION_PREFS };
116
+ function compareVersions(a, b) {
117
+ const aParts = String(a || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
118
+ const bParts = String(b || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
119
+ const len = Math.max(aParts.length, bParts.length);
120
+ for (let i = 0; i < len; i++) {
121
+ const diff = (aParts[i] || 0) - (bParts[i] || 0);
122
+ if (diff !== 0) return diff;
204
123
  }
124
+ return 0;
205
125
  }
206
126
 
207
- function saveSessionPrefs(workspace, prefs) {
208
- const claudeDir = join(workspace, '.claude');
209
- mkdirSync(claudeDir, { recursive: true });
210
- const prefsPath = join(workspace, SESSION_PREFS_REL);
211
- writeAtomicJson(prefsPath, { ...DEFAULT_SESSION_PREFS, ...prefs });
212
- }
213
-
214
- function getClaudeCredentialPaths(workspace = process.cwd()) {
215
- const configDir = process.env.CLAUDE_CONFIG_DIR;
216
- const home = process.env.HOME || '';
217
- return [
218
- configDir ? join(configDir, '.credentials.json') : null,
219
- join(home, '.claude', '.credentials.json'),
220
- resolve(workspace, '.replit-tools', '.claude-persistent', '.credentials.json'),
221
- ].filter(Boolean);
222
- }
223
-
224
- function safeParseJson(path) {
127
+ function readJsonFile(path) {
225
128
  try {
226
129
  return JSON.parse(readFileSync(path, 'utf8'));
227
130
  } catch {
@@ -229,189 +132,106 @@ function safeParseJson(path) {
229
132
  }
230
133
  }
231
134
 
232
- function decodeJwtPayload(token) {
233
- if (!token || typeof token !== 'string') return null;
234
- const parts = token.split('.');
235
- if (parts.length < 2) return null;
236
- try {
237
- return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
238
- } catch {
239
- return null;
240
- }
241
- }
242
-
243
- function computeExpiresInHours(expiresAtMs) {
244
- if (!Number.isFinite(expiresAtMs)) return null;
245
- return Math.round(((expiresAtMs - Date.now()) / 3_600_000) * 10) / 10;
135
+ function writeJsonFile(path, value) {
136
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
246
137
  }
247
138
 
248
- function formatExpires(expiresInHours) {
249
- if (expiresInHours == null) return 'unknown';
250
- if (expiresInHours <= 0) return 'expired';
251
- return `${Math.round(expiresInHours)}h remaining`;
139
+ function getVersionStamp(workspace) {
140
+ return readJsonFile(join(workspace, VERSION_STAMP_FILE));
252
141
  }
253
142
 
254
- function getClaudeAuthStatus() {
255
- const defaults = {
256
- valid: false,
257
- expiresInHours: null,
258
- hasRefreshToken: false,
259
- email: null,
260
- credPath: null,
143
+ function writeVersionStamp(workspace) {
144
+ const target = join(workspace, VERSION_STAMP_FILE);
145
+ const now = new Date().toISOString();
146
+ const existing = readJsonFile(target);
147
+ const stamp = {
148
+ version: VERSION,
149
+ installed_at: existing?.installed_at || now,
150
+ updated_at: now,
261
151
  };
262
-
263
- for (const credPath of getClaudeCredentialPaths(process.cwd())) {
264
- const cred = safeParseJson(credPath);
265
- const oauth = cred?.claudeAiOauth;
266
- if (!oauth) continue;
267
-
268
- const expiresAtMs = typeof oauth.expiresAt === 'number'
269
- ? oauth.expiresAt
270
- : oauth.expiresAt
271
- ? Date.parse(oauth.expiresAt)
272
- : NaN;
273
- const expiresInHours = computeExpiresInHours(expiresAtMs);
274
- const valid = Number.isFinite(expiresAtMs) ? expiresAtMs > Date.now() : false;
275
-
276
- let subscription = null;
277
- try {
278
- const authOut = run('claude', ['auth', 'status', '--json']);
279
- if (authOut.status === 0) {
280
- const info = JSON.parse(authOut.stdout.trim());
281
- subscription = info.subscriptionType || null;
282
- }
283
- } catch {}
284
- if (!subscription) subscription = oauth.subscriptionType || null;
285
-
286
- return {
287
- valid,
288
- expiresInHours,
289
- hasRefreshToken: !!oauth.refreshToken,
290
- email: oauth.email || oauth.account?.email || cred.email || null,
291
- subscription,
292
- credPath,
293
- };
294
- }
295
-
296
- return defaults;
152
+ writeJsonFile(target, stamp);
153
+ return stamp;
297
154
  }
298
155
 
299
- function getCodexAuthStatus() {
300
- const authPath = join(process.env.HOME || '', '.codex', 'auth.json');
301
- const auth = safeParseJson(authPath);
302
- const tokens = auth?.tokens || auth;
303
- const accessToken = tokens?.access_token;
304
- const refreshToken = tokens?.refresh_token;
305
- const payload = decodeJwtPayload(accessToken);
306
- const expiresAtMs = payload?.exp ? payload.exp * 1000 : NaN;
307
- const expiresInHours = computeExpiresInHours(expiresAtMs);
308
-
309
- return {
310
- valid: Number.isFinite(expiresAtMs) ? expiresAtMs > Date.now() : false,
311
- expiresInHours,
312
- hasRefreshToken: !!refreshToken,
313
- email: payload?.email || auth?.email || auth?.user?.email || null,
314
- };
156
+ function getUpdateCache(workspace) {
157
+ const cache = readJsonFile(join(workspace, UPDATE_CACHE_FILE));
158
+ if (!cache?.checked_at) return null;
159
+ const age = Date.now() - Date.parse(cache.checked_at);
160
+ if (!Number.isFinite(age) || age < 0 || age > UPDATE_CACHE_TTL_MS) return null;
161
+ return cache;
315
162
  }
316
163
 
317
- function postForm(url, body) {
318
- return new Promise((resolve, reject) => {
319
- const payload = Buffer.from(new URLSearchParams(body).toString(), 'utf8');
320
- const req = https.request(url, {
321
- method: 'POST',
322
- headers: {
323
- 'Content-Type': 'application/x-www-form-urlencoded',
324
- 'Content-Length': payload.length,
325
- },
326
- timeout: 8000,
327
- }, (res) => {
328
- const chunks = [];
329
- res.on('data', (chunk) => chunks.push(chunk));
330
- res.on('end', () => {
331
- const raw = Buffer.concat(chunks).toString('utf8');
332
- if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
333
- try {
334
- resolve(JSON.parse(raw));
335
- } catch (err) {
336
- reject(err);
337
- }
338
- } else {
339
- reject(new Error(`HTTP ${res.statusCode || 0}`));
340
- }
341
- });
342
- });
343
- req.on('timeout', () => req.destroy(new Error('Request timeout')));
344
- req.on('error', reject);
345
- req.write(payload);
346
- req.end();
164
+ function writeUpdateCache(workspace, result) {
165
+ writeJsonFile(join(workspace, UPDATE_CACHE_FILE), {
166
+ checked_at: new Date().toISOString(),
167
+ ...result,
347
168
  });
348
169
  }
349
170
 
350
- async function refreshClaudeToken(credPath) {
171
+ function checkForUpdate(workspace, { force = false } = {}) {
172
+ const installedStamp = getVersionStamp(workspace);
173
+ const installed = installedStamp?.version || VERSION;
174
+
175
+ if (!force) {
176
+ const cached = getUpdateCache(workspace);
177
+ if (cached && cached.installed === installed) {
178
+ return {
179
+ updateAvailable: !!cached.updateAvailable,
180
+ installed: cached.installed,
181
+ latest: cached.latest || installed,
182
+ checkedAt: cached.checked_at,
183
+ };
184
+ }
185
+ }
186
+
351
187
  try {
352
- const cred = safeParseJson(credPath);
353
- const oauth = cred?.claudeAiOauth;
354
- if (!oauth?.refreshToken) return { success: false, expiresInHours: null };
355
-
356
- const refreshed = await postForm('https://console.anthropic.com/v1/oauth/token', {
357
- grant_type: 'refresh_token',
358
- refresh_token: oauth.refreshToken,
359
- client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
188
+ const result = spawnSync('npm', ['view', 'dual-brain', 'version', '--json'], {
189
+ encoding: 'utf8',
190
+ stdio: ['pipe', 'pipe', 'pipe'],
191
+ timeout: 5000,
360
192
  });
193
+ if (result.status !== 0 || !result.stdout.trim()) {
194
+ return null;
195
+ }
361
196
 
362
- const nextOauth = {
363
- ...oauth,
364
- accessToken: refreshed.access_token || oauth.accessToken,
365
- refreshToken: refreshed.refresh_token || oauth.refreshToken,
366
- tokenType: refreshed.token_type || oauth.tokenType,
367
- scopes: refreshed.scope || oauth.scopes,
368
- expiresAt: Date.now() + ((refreshed.expires_in || 0) * 1000),
369
- };
370
- const updated = { ...cred, claudeAiOauth: nextOauth };
371
- writeAtomicJson(credPath, updated);
197
+ const latestRaw = JSON.parse(result.stdout);
198
+ const latest = Array.isArray(latestRaw) ? latestRaw[latestRaw.length - 1] : latestRaw;
199
+ if (!latest) return null;
372
200
 
373
- return {
374
- success: true,
375
- expiresInHours: computeExpiresInHours(nextOauth.expiresAt),
376
- };
201
+ const updateAvailable = compareVersions(latest, installed) > 0;
202
+ const payload = { updateAvailable, installed, latest };
203
+ writeUpdateCache(workspace, payload);
204
+ return payload;
377
205
  } catch {
378
- return { success: false, expiresInHours: null };
206
+ return null;
379
207
  }
380
208
  }
381
209
 
382
- async function refreshCodexToken() {
383
- try {
384
- const authPath = join(process.env.HOME || '', '.codex', 'auth.json');
385
- const auth = safeParseJson(authPath);
386
- const tokens = auth?.tokens || auth;
387
- const refreshToken = tokens?.refresh_token;
388
- if (!refreshToken) return { success: false, expiresInHours: null };
389
-
390
- const refreshed = await postForm('https://auth.openai.com/oauth/token', {
391
- grant_type: 'refresh_token',
392
- refresh_token: refreshToken,
393
- client_id: 'app_EMoamEEZ73f0CkXaXp7hrann',
210
+ function performUpdate(workspace, env, mode) {
211
+ return install(workspace, env, mode);
212
+ }
213
+
214
+ function promptForUpdate(updateInfo) {
215
+ return new Promise((resolve) => {
216
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
217
+ rl.question(`Update available: v${updateInfo.installed} → v${updateInfo.latest}. Press [u] to update or Enter to continue. `, (answer) => {
218
+ rl.close();
219
+ resolve(answer.trim().toLowerCase() === 'u');
394
220
  });
221
+ });
222
+ }
395
223
 
396
- const updatedTokens = {
397
- ...tokens,
398
- access_token: refreshed.access_token || tokens.access_token,
399
- refresh_token: refreshed.refresh_token || tokens.refresh_token,
400
- id_token: refreshed.id_token || tokens.id_token,
401
- token_type: refreshed.token_type || tokens.token_type,
402
- scope: refreshed.scope || tokens.scope,
403
- };
404
- const updated = auth?.tokens ? { ...auth, tokens: updatedTokens } : updatedTokens;
405
- writeAtomicJson(authPath, updated);
224
+ function isLoggedInStatus(result) {
225
+ const out = ((result?.stdout || '') + (result?.stderr || '')).toLowerCase();
226
+ if (/\b(not\s+logged\s+in|unauthenticated|logged\s+out|no\s+auth)\b/.test(out)) return false;
227
+ return result?.status === 0 ||
228
+ /\b(logged\s+in|authenticated|signed\s+in|valid\s+session)\b/.test(out);
229
+ }
406
230
 
407
- const payload = decodeJwtPayload(updatedTokens.access_token);
408
- return {
409
- success: true,
410
- expiresInHours: payload?.exp ? computeExpiresInHours(payload.exp * 1000) : null,
411
- };
412
- } catch {
413
- return { success: false, expiresInHours: null };
414
- }
231
+ function detectReplit() {
232
+ const isReplit = !!(process.env.REPL_ID || process.env.REPL_SLUG);
233
+ const hasReplitTools = existsSync(resolve(process.cwd(), '.replit-tools'));
234
+ return { isReplit, hasReplitTools };
415
235
  }
416
236
 
417
237
  function detectClaude() {
@@ -455,7 +275,7 @@ function detectClaude() {
455
275
  }
456
276
 
457
277
  function detectCodex() {
458
- const result = { installed: false, version: null, authed: false, path: null };
278
+ const result = { installed: false, version: null, authed: false, path: null, authMethod: null };
459
279
 
460
280
  const which = run('which', ['codex']);
461
281
  if (which.status === 0 && which.stdout.trim()) {
@@ -480,12 +300,17 @@ function detectCodex() {
480
300
  if (ver.status === 0) result.version = ver.stdout.trim().split('\n')[0];
481
301
 
482
302
  const login = run(result.path, ['login', 'status']);
483
- const out = ((login.stdout || '') + (login.stderr || '')).toLowerCase();
484
- if (login.status === 0 || out.includes('logged in') || out.includes('authenticated')) {
303
+ if (isLoggedInStatus(login)) {
485
304
  result.authed = true;
305
+ result.authMethod = 'oauth';
486
306
  }
487
307
  }
488
308
 
309
+ if (!result.authed && process.env.OPENAI_API_KEY) {
310
+ result.authed = true;
311
+ result.authMethod = 'api_key_env';
312
+ }
313
+
489
314
  return result;
490
315
  }
491
316
 
@@ -509,49 +334,324 @@ function detectEnvironment() {
509
334
  };
510
335
  }
511
336
 
512
- function runtimeInstalled(workspace = process.cwd()) {
513
- return existsSync(resolve(workspace, '.replit-tools'));
514
- }
337
+ // ─── NPM Token Persistence ────────────────────────────────────────────────
515
338
 
516
- function providerReadiness(env) {
517
- const issues = [];
518
- if (!env.claude.installed) issues.push('Claude CLI not found.');
519
- else if (!env.claude.authed) issues.push('Claude CLI not authenticated.');
339
+ const NPM_PERSIST = resolve(process.cwd(), '.replit-tools', '.npm-persistent', '.npmrc');
340
+ const NPMRC_HOME = join(process.env.HOME || '', '.npmrc');
520
341
 
521
- if (!env.codex.installed) issues.push('Codex CLI not found.');
522
- else if (!env.codex.authed) issues.push('Codex CLI not authenticated.');
342
+ function restoreNpmToken() {
343
+ if (existsSync(NPMRC_HOME)) return;
344
+ if (!existsSync(NPM_PERSIST)) return;
345
+ try {
346
+ const content = readFileSync(NPM_PERSIST, 'utf8');
347
+ if (content.includes('_authToken')) {
348
+ writeFileSync(NPMRC_HOME, content);
349
+ }
350
+ } catch {}
351
+ }
523
352
 
524
- return { ready: issues.length === 0, issues };
353
+ // ─── Auth Self-Healing ─────────────────────────────────────────────────────
354
+
355
+ function healClaudeAuth(env) {
356
+ if (env.claude.authed) return env;
357
+ const refreshScript = resolve(process.cwd(), '.replit-tools', 'scripts', 'claude-auth-refresh.sh');
358
+ if (existsSync(refreshScript)) {
359
+ const result = run('bash', [refreshScript, '--auto']);
360
+ const out = ((result.stdout || '') + (result.stderr || '')).toLowerCase();
361
+ if (out.includes('refreshed') || out.includes('success')) {
362
+ env.claude.authed = true;
363
+ console.log(' 🔄 Claude token refreshed via data-tools');
364
+ }
365
+ }
366
+ return env;
525
367
  }
526
368
 
527
- function readInputLine(prompt) {
528
- process.stdout.write(prompt);
529
- return new Promise((resolve) => {
530
- process.stdin.setEncoding('utf8');
531
- process.stdin.once('data', (chunk) => resolve(chunk.trim()));
532
- process.stdin.resume();
533
- }).finally(() => {
534
- process.stdin.pause();
535
- });
369
+ function healCodexAuth(env) {
370
+ if (env.codex.authed) return env;
371
+ if (!env.codex.installed || !env.codex.path) return env;
372
+
373
+ // Try restoring persisted credentials from data-tools
374
+ if (restoreCodexCredentials()) {
375
+ const login = run(env.codex.path, ['login', 'status']);
376
+ if (isLoggedInStatus(login)) {
377
+ env.codex.authed = true;
378
+ env.codex.authMethod = 'restored_persistent';
379
+ console.log(' 🔄 Codex credentials restored from data-tools');
380
+ return env;
381
+ }
382
+ }
383
+
384
+ if (process.env.OPENAI_API_KEY) {
385
+ const pipe = spawnSync(env.codex.path, ['login', '--with-api-key'], {
386
+ input: process.env.OPENAI_API_KEY,
387
+ encoding: 'utf8',
388
+ stdio: ['pipe', 'pipe', 'pipe'],
389
+ timeout: 10000,
390
+ });
391
+ if (pipe.status === 0) {
392
+ env.codex.authed = true;
393
+ env.codex.authMethod = 'api_key_env';
394
+ saveCodexCredentials();
395
+ console.log(' 🔄 Codex authenticated via OPENAI_API_KEY');
396
+ return env;
397
+ }
398
+ }
399
+
400
+ return env;
536
401
  }
537
402
 
538
- function isRunningFromNpxCache() {
539
- const scriptPath = fileURLToPath(import.meta.url);
540
- return scriptPath.includes('/.npm/_npx/') || scriptPath.includes('\\.npm\\_npx\\');
403
+ function prompt(question) {
404
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
405
+ return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
541
406
  }
542
407
 
543
- function warnIfUpdateAvailable() {
544
- if (!isRunningFromNpxCache()) return;
545
- const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
546
- const packageName = pkg.name || 'dual-brain';
547
- const latest = run('npm', ['view', packageName, 'version']);
548
- if (latest.status !== 0) return;
549
- const newest = latest.stdout.trim().split('\n').pop();
550
- if (newest && newest !== VERSION) {
408
+ async function authGuidance(env) {
409
+ if (env.claude.authed && env.codex.authed) return env;
410
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return env;
411
+
412
+ console.log('');
413
+ console.log(' ┌────────────────────────────────────────────┐');
414
+ console.log(' │ 🔑 Auth Setup │');
415
+ console.log(' └────────────────────────────────────────────┘');
416
+
417
+ if (!env.claude.authed) {
551
418
  console.log('');
552
- console.log(` Update available: ${cmd('npx -y dual-brain@latest')}`);
419
+ console.log(' 🟠 Claude not authenticated');
420
+ if (env.isReplit) {
421
+ console.log(' Run in your terminal: ! claude login');
422
+ console.log(' It will give you a URL + code to paste in your browser.');
423
+ } else {
424
+ console.log(' Run: claude login');
425
+ }
426
+ }
427
+
428
+ if (!env.codex.authed) {
553
429
  console.log('');
430
+ console.log(' 🟢 Codex — not authenticated');
431
+ if (!env.codex.installed) {
432
+ console.log(' Install first: npm i -g @openai/codex');
433
+ console.log('');
434
+ }
435
+
436
+ if (env.codex.installed && env.codex.path) {
437
+ console.log('');
438
+ console.log(' Sign in with your ChatGPT subscription (no API key needed):');
439
+ console.log('');
440
+
441
+ const answer = await prompt(' Press Enter to start device auth (or "skip" to skip): ');
442
+ if (answer.toLowerCase() === 'skip') {
443
+ console.log(' ⏭️ Skipped — GPT features disabled until Codex is authed');
444
+ } else {
445
+ console.log('');
446
+ if (runCodexDeviceAuth(env.codex.path)) {
447
+ env.codex.authed = true;
448
+ env.codex.authMethod = 'device_auth';
449
+ console.log('');
450
+ console.log(' ✅ Codex authenticated! (credentials saved for next session)');
451
+ } else {
452
+ console.log('');
453
+ console.log(' ❌ Auth failed — try again with: codex login --device-auth');
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ console.log('');
460
+ return env;
461
+ }
462
+
463
+ // ─── Codex Credential Persistence ──────────────────────────────────────────
464
+
465
+ const CODEX_HOME = join(process.env.HOME || '', '.codex');
466
+ const CODEX_PERSIST = resolve(process.cwd(), '.replit-tools', '.codex-persistent');
467
+
468
+ function saveCodexCredentials() {
469
+ const authFile = join(CODEX_HOME, 'auth.json');
470
+ if (!existsSync(authFile)) return false;
471
+ try {
472
+ const auth = readFileSync(authFile, 'utf8');
473
+ if (!auth.trim() || auth.trim() === '{}') return false;
474
+ mkdirSync(CODEX_PERSIST, { recursive: true });
475
+ const persisted = join(CODEX_PERSIST, 'auth.json');
476
+ writeFileSync(persisted, auth, { mode: 0o600 });
477
+ try { chmodSync(persisted, 0o600); } catch {}
478
+ return true;
479
+ } catch { return false; }
480
+ }
481
+
482
+ function restoreCodexCredentials() {
483
+ const persistedAuth = join(CODEX_PERSIST, 'auth.json');
484
+ const targetAuth = join(CODEX_HOME, 'auth.json');
485
+ if (existsSync(targetAuth)) return false;
486
+ if (!existsSync(persistedAuth)) return false;
487
+ try {
488
+ const auth = readFileSync(persistedAuth, 'utf8');
489
+ if (!auth.trim() || auth.trim() === '{}') return false;
490
+ mkdirSync(CODEX_HOME, { recursive: true });
491
+ writeFileSync(targetAuth, auth, { mode: 0o600 });
492
+ try { chmodSync(targetAuth, 0o600); } catch {}
493
+ return true;
494
+ } catch { return false; }
495
+ }
496
+
497
+ function parseDateValue(value) {
498
+ if (!value) return null;
499
+ if (typeof value === 'number') {
500
+ const ms = value > 1e12 ? value : value * 1000;
501
+ const d = new Date(ms);
502
+ return Number.isNaN(d.getTime()) ? null : d;
503
+ }
504
+ const d = new Date(value);
505
+ return Number.isNaN(d.getTime()) ? null : d;
506
+ }
507
+
508
+ function formatExpiry(value) {
509
+ const d = parseDateValue(value);
510
+ if (!d) return 'unknown';
511
+ const iso = d.toISOString();
512
+ return d.getTime() <= Date.now() ? `${iso} (expired)` : iso;
513
+ }
514
+
515
+ function formatTimestamp(value) {
516
+ const d = parseDateValue(value);
517
+ return d ? d.toISOString() : 'unknown';
518
+ }
519
+
520
+ function relPath(p) {
521
+ if (!p) return 'unknown';
522
+ const cwd = resolve(process.cwd());
523
+ const full = resolve(p);
524
+ if (full.startsWith(cwd + '/')) return full.slice(cwd.length + 1);
525
+ const home = process.env.HOME || '';
526
+ if (home && full.startsWith(home + '/')) return `~/${full.slice(home.length + 1)}`;
527
+ return full;
528
+ }
529
+
530
+ function readJsonIfExists(file) {
531
+ if (!file || !existsSync(file)) return null;
532
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
533
+ }
534
+
535
+ function claudeCredentialCandidates() {
536
+ return [
537
+ join(process.env.HOME || '', '.claude', '.credentials.json'),
538
+ join(process.env.HOME || '', '.claude', 'credentials.json'),
539
+ resolve(process.cwd(), '.replit-tools', '.claude-persistent', '.credentials.json'),
540
+ ];
541
+ }
542
+
543
+ function getClaudeAuthDetails() {
544
+ const detected = detectClaude();
545
+ const status = detected.installed ? run('claude', ['auth', 'status']) : null;
546
+ let method = 'none';
547
+ let storage = null;
548
+ let expiry = null;
549
+
550
+ for (const file of claudeCredentialCandidates()) {
551
+ const cred = readJsonIfExists(file);
552
+ if (!cred) continue;
553
+ if (cred.claudeAiOauth) {
554
+ method = 'oauth';
555
+ storage = file;
556
+ expiry = cred.claudeAiOauth.expiresAt || null;
557
+ break;
558
+ }
559
+ if (cred.apiKey || cred.oauth_token) {
560
+ method = cred.apiKey ? 'api_key' : 'oauth_token';
561
+ storage = file;
562
+ break;
563
+ }
564
+ }
565
+
566
+ const statusText = ((status?.stdout || '') + (status?.stderr || '')).trim();
567
+ return {
568
+ provider: 'claude',
569
+ installed: detected.installed,
570
+ version: detected.version,
571
+ authed: detected.authed,
572
+ method,
573
+ expiry,
574
+ expiryText: formatExpiry(expiry),
575
+ storage,
576
+ storageText: relPath(storage),
577
+ statusText,
578
+ };
579
+ }
580
+
581
+ function getCodexAuthDetails() {
582
+ const detected = detectCodex();
583
+ const status = (detected.installed && detected.path) ? run(detected.path, ['login', 'status']) : null;
584
+ const authFile = join(CODEX_HOME, 'auth.json');
585
+ const persisted = join(CODEX_PERSIST, 'auth.json');
586
+ const activeFile = existsSync(authFile) ? authFile : existsSync(persisted) ? persisted : null;
587
+ const auth = readJsonIfExists(activeFile);
588
+
589
+ let method = detected.authMethod || 'none';
590
+ if (auth?.auth_mode === 'chatgpt') method = 'chatgpt_device_auth';
591
+ else if (auth?.auth_mode === 'api_key') method = 'api_key';
592
+ else if (!detected.authed && process.env.OPENAI_API_KEY) method = 'api_key_env';
593
+
594
+ const lastRefresh = auth?.last_refresh || null;
595
+ const statusText = ((status?.stdout || '') + (status?.stderr || '')).trim();
596
+ return {
597
+ provider: 'codex',
598
+ installed: detected.installed,
599
+ version: detected.version,
600
+ authed: detected.authed,
601
+ method,
602
+ expiry: null,
603
+ expiryText: 'n/a',
604
+ lastRefresh,
605
+ lastRefreshText: formatTimestamp(lastRefresh),
606
+ storage: activeFile,
607
+ storageText: relPath(activeFile),
608
+ statusText,
609
+ path: detected.path,
610
+ };
611
+ }
612
+
613
+ function getAuthState() {
614
+ return {
615
+ claude: getClaudeAuthDetails(),
616
+ codex: getCodexAuthDetails(),
617
+ };
618
+ }
619
+
620
+ function printAuthStatusBox(state) {
621
+ const c = state.claude;
622
+ const x = state.codex;
623
+ const cIcon = c.authed ? '✅' : c.installed ? '⚠️' : '❌';
624
+ const xIcon = x.authed ? '✅' : x.installed ? '⚠️' : '❌';
625
+
626
+ console.log('');
627
+ console.log(` ${br('╔', '╗')}`);
628
+ console.log(` ${ln(`🔑 Auth Status`)}`);
629
+ console.log(` ${sep()}`);
630
+ console.log(` ${ln(`🟠 Claude ${cIcon} ${c.authed ? 'authenticated' : c.installed ? 'not authenticated' : 'not installed'}`)}`);
631
+ console.log(` ${ln(` Method: ${c.method}`)}`);
632
+ console.log(` ${ln(` Expiry: ${c.expiryText}`)}`);
633
+ console.log(` ${ln(` Storage: ${c.storageText}`)}`);
634
+ console.log(` ${sep()}`);
635
+ console.log(` ${ln(`🟢 Codex ${xIcon} ${x.authed ? 'authenticated' : x.installed ? 'not authenticated' : 'not installed'}`)}`);
636
+ console.log(` ${ln(` Method: ${x.method}`)}`);
637
+ console.log(` ${ln(` Expiry: ${x.expiryText}`)}`);
638
+ console.log(` ${ln(` Storage: ${x.storageText}`)}`);
639
+ if (x.lastRefresh) console.log(` ${ln(` Refreshed:${x.lastRefreshText}`)}`);
640
+ console.log(` ${br('╚', '╝')}`);
641
+ console.log('');
642
+ }
643
+
644
+ function runCodexDeviceAuth(codexPath) {
645
+ if (!codexPath) return false;
646
+ const result = spawnSync(codexPath, ['login', '--device-auth'], {
647
+ stdio: 'inherit',
648
+ timeout: 900000,
649
+ });
650
+ if (result.status === 0) {
651
+ saveCodexCredentials();
652
+ return true;
554
653
  }
654
+ return false;
555
655
  }
556
656
 
557
657
  // ─── Mode Resolution ────────────────────────────────────────────────────────
@@ -618,7 +718,7 @@ function generateSettings(workspace) {
618
718
  ],
619
719
  PostToolUse: [
620
720
  {
621
- matcher: 'Agent|Bash|Write|Edit',
721
+ matcher: '',
622
722
  hooks: [{ type: 'command', command: 'node .claude/hooks/cost-logger.mjs' }],
623
723
  },
624
724
  ],
@@ -667,6 +767,8 @@ function generateGitignoreEntries(workspace) {
667
767
  '.claude/hooks/decision-ledger.jsonl',
668
768
  '.claude/.launched',
669
769
  '.claude/dual-brain.memory.json',
770
+ '.claude/dual-brain.version.json',
771
+ '.claude/dual-brain.update-check.json',
670
772
  ];
671
773
  let existing = '';
672
774
  try { existing = readFileSync(join(workspace, '.gitignore'), 'utf8'); } catch {}
@@ -691,8 +793,6 @@ function install(workspace, env, mode) {
691
793
  'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
692
794
  'risk-classifier.mjs', 'failure-detector.mjs',
693
795
  'vibe-router.mjs', 'plan-generator.mjs', 'vibe-memory.mjs',
694
- 'agent-templates.mjs', 'agent-chains.mjs',
695
- 'ship-captain.mjs', 'ship-gate.mjs', 'confirmation-policy.mjs',
696
796
  ];
697
797
  for (const h of HOOKS) cpSync(join(__dirname, 'hooks', h), join(target, 'hooks', h));
698
798
  actions.push(`✓ ${HOOKS.length} hook scripts`);
@@ -734,21 +834,10 @@ function install(workspace, env, mode) {
734
834
  actions.push('✓ .gitignore updated');
735
835
  }
736
836
 
737
- return actions;
738
- }
739
-
740
- // ─── Quick Start Block ───────────────────────────────────────────────────────
837
+ const stamp = writeVersionStamp(workspace);
838
+ actions.push(`✓ version stamp (v${stamp.version})`);
741
839
 
742
- function printQuickStart() {
743
- console.log('');
744
- console.log(` ${BRAND}`);
745
- console.log('');
746
- console.log(' Quick start:');
747
- console.log(' npx dual-brain do "fix the login bug" ← one command to PR');
748
- console.log(' npx dual-brain setup ← configure providers');
749
- console.log(' npx dual-brain doctor ← check system health');
750
- console.log(' npx dual-brain help ← full command list');
751
- console.log('');
840
+ return actions;
752
841
  }
753
842
 
754
843
  // ─── Status Report ──────────────────────────────────────────────────────────
@@ -757,14 +846,22 @@ function printReport(env, mode, actions, isDryRun) {
757
846
  const lines = [];
758
847
 
759
848
  lines.push(br('╔', '╗'));
760
- lines.push(ln(BRAND));
761
- lines.push(ln(BRAND_SUBTITLE));
849
+ lines.push(ln(`🧠 Dual-Brain v${VERSION}`));
762
850
  lines.push(sep());
763
- lines.push(ln(`Workspace: ${env.workspace}`));
851
+
852
+ const cAuth = env.claude.authed ? '✅' : env.claude.installed ? '⚠️' : '❌';
853
+ const xAuth = env.codex.authed ? '✅' : env.codex.installed ? '⚠️' : '❌';
854
+ lines.push(ln(` 🟠 Claude ${cAuth} 🟢 Codex ${xAuth}`));
855
+
856
+ if (env.isReplit) {
857
+ lines.push(ln(` 🌀 Replit${env.hasReplitTools ? ' + replit-tools' : ''}`));
858
+ }
764
859
 
765
860
  if (actions) {
766
861
  lines.push(sep());
767
862
  for (const a of actions) lines.push(ln(` ${a}`));
863
+ lines.push(sep());
864
+ lines.push(ln('✅ Installed — launching session manager...'));
768
865
  } else if (isDryRun) {
769
866
  lines.push(sep());
770
867
  lines.push(ln('Dry run — no files written'));
@@ -851,6 +948,132 @@ function launchPanel() {
851
948
  }
852
949
  }
853
950
 
951
+ // ─── Subcommand: auth ──────────────────────────────────────────────────────
952
+
953
+ function cmdAuthStatus() {
954
+ const state = getAuthState();
955
+ if (jsonOut) {
956
+ console.log(JSON.stringify({ version: VERSION, auth: state }, null, 2));
957
+ return;
958
+ }
959
+ printAuthStatusBox(state);
960
+ console.log(` Login: ${cmd('npx dual-brain auth login')}`);
961
+ console.log(` Refresh: ${cmd('npx dual-brain auth refresh')}`);
962
+ console.log('');
963
+ }
964
+
965
+ function cmdAuthLogin() {
966
+ const state = getAuthState();
967
+
968
+ console.log('');
969
+ if (!state.claude.authed) {
970
+ if (!state.claude.installed) {
971
+ console.log(' 🟠 Claude is not installed.');
972
+ } else {
973
+ console.log(' 🟠 Claude needs login.');
974
+ console.log(` Run: ${cmd('claude login')}`);
975
+ console.log(' Claude uses its own browser/device flow from the CLI.');
976
+ }
977
+ console.log('');
978
+ }
979
+
980
+ if (!state.codex.authed) {
981
+ if (!state.codex.installed || !state.codex.path) {
982
+ console.log(' 🟢 Codex is not installed.');
983
+ console.log(' Install first: npm i -g @openai/codex');
984
+ console.log('');
985
+ } else if (!process.stdin.isTTY || !process.stdout.isTTY) {
986
+ console.log(' 🟢 Codex login requires an interactive terminal.');
987
+ console.log(` Run: ${cmd('codex login --device-auth')}`);
988
+ console.log('');
989
+ } else {
990
+ console.log(' 🟢 Starting Codex device auth...');
991
+ console.log('');
992
+ if (runCodexDeviceAuth(state.codex.path)) {
993
+ console.log('');
994
+ console.log(' ✅ Codex authenticated and credentials saved.');
995
+ } else {
996
+ console.log('');
997
+ console.log(` ❌ Codex auth failed. Retry with: ${cmd('codex login --device-auth')}`);
998
+ }
999
+ console.log('');
1000
+ }
1001
+ }
1002
+
1003
+ if (state.claude.authed && state.codex.authed) {
1004
+ console.log(' ✅ Claude and Codex are already authenticated.');
1005
+ console.log('');
1006
+ return;
1007
+ }
1008
+
1009
+ const finalState = getAuthState();
1010
+ if (finalState.claude.authed && finalState.codex.authed) {
1011
+ printAuthStatusBox(finalState);
1012
+ return;
1013
+ }
1014
+
1015
+ printAuthStatusBox(finalState);
1016
+ }
1017
+
1018
+ function cmdAuthRefresh() {
1019
+ let env = detectEnvironment();
1020
+ let codexRefreshed = false;
1021
+
1022
+ console.log('');
1023
+ console.log(' 🔄 Refreshing provider auth...');
1024
+ console.log('');
1025
+
1026
+ env = { ...env, claude: { ...env.claude, authed: false } };
1027
+ env = healClaudeAuth(env);
1028
+
1029
+ if (env.codex.installed && env.codex.path && process.stdin.isTTY && process.stdout.isTTY) {
1030
+ console.log(' 🟢 Codex device auth refresh');
1031
+ console.log('');
1032
+ codexRefreshed = runCodexDeviceAuth(env.codex.path);
1033
+ if (codexRefreshed) {
1034
+ env.codex.authed = true;
1035
+ env.codex.authMethod = 'device_auth';
1036
+ console.log('');
1037
+ console.log(' ✅ Codex auth refreshed and credentials saved.');
1038
+ console.log('');
1039
+ } else {
1040
+ console.log('');
1041
+ console.log(` ❌ Codex refresh failed. Retry with: ${cmd('codex login --device-auth')}`);
1042
+ console.log('');
1043
+ }
1044
+ } else {
1045
+ env = { ...env, codex: { ...env.codex, authed: false } };
1046
+ env = healCodexAuth(env);
1047
+ }
1048
+
1049
+ if (env.claude.installed && !env.claude.authed) {
1050
+ console.log(` 🟠 Claude refresh unavailable here. Run: ${cmd('claude login')}`);
1051
+ console.log('');
1052
+ }
1053
+ if (env.codex.installed && !env.codex.authed && !codexRefreshed) {
1054
+ console.log(` 🟢 Codex refresh incomplete. Run: ${cmd('codex login --device-auth')}`);
1055
+ console.log('');
1056
+ }
1057
+
1058
+ const finalState = getAuthState();
1059
+ if (jsonOut) {
1060
+ console.log(JSON.stringify({ version: VERSION, auth: finalState }, null, 2));
1061
+ return;
1062
+ }
1063
+ printAuthStatusBox(finalState);
1064
+ }
1065
+
1066
+ function cmdAuth() {
1067
+ const action = positional[1] || 'status';
1068
+ if (action === 'status') { cmdAuthStatus(); return; }
1069
+ if (action === 'login') { cmdAuthLogin(); return; }
1070
+ if (action === 'refresh') { cmdAuthRefresh(); return; }
1071
+
1072
+ console.error(` Unknown auth command: ${action}`);
1073
+ console.error(` Run: ${cmd('npx dual-brain auth [status|login|refresh]')}`);
1074
+ process.exit(1);
1075
+ }
1076
+
854
1077
  // ─── Subcommand: mode ──────────────────────────────────────────────────────
855
1078
 
856
1079
  function cmdMode() {
@@ -1056,1223 +1279,58 @@ function cmdExplain() {
1056
1279
  console.log('');
1057
1280
  }
1058
1281
 
1059
- // ─── Subcommand: doctor ───────────────────────────────────────────────────
1060
-
1061
- function cmdDoctor() {
1062
- const workspace = resolve(process.cwd());
1063
- const claudeDir = join(workspace, '.claude');
1064
- const hooksDir = join(claudeDir, 'hooks');
1065
- const results = [];
1282
+ // ─── Main ───────────────────────────────────────────────────────────────────
1066
1283
 
1067
- // 1. Hook installation check
1068
- const settingsPath = join(claudeDir, 'settings.json');
1069
- const DUAL_BRAIN_CMDS = [
1070
- 'node .claude/hooks/enforce-tier.mjs',
1071
- 'node .claude/hooks/cost-logger.mjs',
1072
- ];
1073
- if (existsSync(settingsPath)) {
1074
- try {
1075
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1076
- const registeredCmds = [];
1077
- if (settings.hooks) {
1078
- for (const entries of Object.values(settings.hooks)) {
1079
- for (const entry of entries) {
1080
- for (const h of (entry.hooks || [])) {
1081
- if (DUAL_BRAIN_CMDS.includes(h.command)) registeredCmds.push(h.command);
1082
- }
1083
- }
1084
- }
1085
- }
1086
- const missing = DUAL_BRAIN_CMDS.filter(c => !registeredCmds.includes(c));
1087
- if (missing.length === 0) {
1088
- results.push({ name: 'Hook registration', status: 'pass', detail: `${DUAL_BRAIN_CMDS.length}/${DUAL_BRAIN_CMDS.length} hooks registered` });
1089
- } else {
1090
- results.push({ name: 'Hook registration', status: 'fail', detail: `Missing: ${missing.map(c => c.split('/').pop()).join(', ')}`, fix: 'Run: npx dual-brain repair' });
1091
- }
1092
- } catch (err) {
1093
- results.push({ name: 'Hook registration', status: 'fail', detail: `settings.json parse error: ${err.message}`, fix: 'Run: npx dual-brain repair' });
1094
- }
1095
- } else {
1096
- results.push({ name: 'Hook registration', status: 'fail', detail: 'settings.json not found', fix: 'Run: npx dual-brain' });
1284
+ async function main() {
1285
+ if (subcommand === 'status') {
1286
+ launchPanel();
1287
+ return;
1097
1288
  }
1289
+ if (subcommand === 'auth') { cmdAuth(); return; }
1290
+ if (subcommand === 'mode') { cmdMode(); return; }
1291
+ if (subcommand === 'budget') { cmdBudget(); return; }
1292
+ if (subcommand === 'explain') { cmdExplain(); return; }
1098
1293
 
1099
- // 2. Config validity
1100
- const orchPath = join(claudeDir, 'orchestrator.json');
1101
- if (existsSync(orchPath)) {
1102
- try {
1103
- const config = JSON.parse(readFileSync(orchPath, 'utf8'));
1104
- const requiredKeys = ['subscriptions', 'tiers', 'providers', 'budgets'];
1105
- const missing = requiredKeys.filter(k => !config[k]);
1106
- if (missing.length === 0) {
1107
- results.push({ name: 'Config validity', status: 'pass', detail: 'orchestrator.json valid, all required keys present' });
1108
- } else {
1109
- results.push({ name: 'Config validity', status: 'warn', detail: `Missing keys: ${missing.join(', ')}`, fix: 'Run: npx dual-brain --force' });
1110
- }
1111
- } catch (err) {
1112
- results.push({ name: 'Config validity', status: 'fail', detail: `orchestrator.json corrupt: ${err.message}`, fix: 'Run: npx dual-brain repair' });
1113
- }
1114
- } else {
1115
- results.push({ name: 'Config validity', status: 'fail', detail: 'orchestrator.json not found', fix: 'Run: npx dual-brain' });
1116
- }
1117
-
1118
- // 3. Auth status
1119
- const claude = detectClaude();
1120
- if (claude.authed) {
1121
- results.push({ name: 'Claude CLI', status: 'pass', detail: 'installed and authenticated' });
1122
- } else if (claude.installed) {
1123
- results.push({ name: 'Claude CLI', status: 'warn', detail: 'installed but not authenticated' });
1124
- } else {
1125
- results.push({ name: 'Claude CLI', status: 'warn', detail: 'not found' });
1126
- }
1127
-
1128
- const codexResult = detectCodex();
1129
- if (codexResult.authed) {
1130
- results.push({ name: 'Codex CLI', status: 'pass', detail: 'installed and authenticated' });
1131
- } else if (codexResult.installed) {
1132
- results.push({ name: 'Codex CLI', status: 'warn', detail: 'installed but not authenticated' });
1133
- } else {
1134
- results.push({ name: 'Codex CLI', status: 'warn', detail: 'not found (GPT features disabled)' });
1135
- }
1136
-
1137
- // 4. State file health
1138
- const stateCheckFiles = [
1139
- { path: join(hooksDir, '.burst-state'), label: '.burst-state', format: 'json' },
1140
- { path: join(hooksDir, 'burst-state.json'), label: 'burst-state.json', format: 'json' },
1141
- { path: join(hooksDir, '.drift-warned'), label: '.drift-warned', format: 'any' },
1142
- { path: join(claudeDir, 'dual-brain.profile.json'), label: 'dual-brain.profile.json', format: 'json' },
1143
- { path: join(hooksDir, 'decision-ledger.jsonl'), label: 'decision-ledger.jsonl', format: 'jsonl' },
1144
- ];
1145
-
1146
- // Add any usage-*.jsonl files
1147
- try {
1148
- for (const f of readdirSync(hooksDir)) {
1149
- if (f.startsWith('usage-') && f.endsWith('.jsonl')) {
1150
- stateCheckFiles.push({ path: join(hooksDir, f), label: f, format: 'jsonl' });
1151
- }
1152
- }
1153
- } catch {}
1154
-
1155
- let stateHealthy = 0, stateWarns = 0, stateFails = 0;
1156
- const stateIssues = [];
1157
-
1158
- for (const sf of stateCheckFiles) {
1159
- if (!existsSync(sf.path)) continue;
1160
- try {
1161
- const raw = readFileSync(sf.path, 'utf8');
1162
- if (sf.format === 'json') {
1163
- JSON.parse(raw);
1164
- stateHealthy++;
1165
- } else if (sf.format === 'jsonl') {
1166
- const lines = raw.split('\n').filter(Boolean);
1167
- let badLines = 0;
1168
- for (const line of lines) {
1169
- try { JSON.parse(line); } catch { badLines++; }
1170
- }
1171
- if (badLines > 0) {
1172
- stateWarns++;
1173
- stateIssues.push(`${sf.label}: ${badLines}/${lines.length} unparseable lines`);
1174
- } else {
1175
- stateHealthy++;
1176
- }
1177
- } else {
1178
- stateHealthy++;
1179
- }
1180
- } catch (err) {
1181
- stateFails++;
1182
- stateIssues.push(`${sf.label}: corrupt (${err.message})`);
1183
- }
1184
- }
1185
-
1186
- // Check for stale locks
1187
- let staleLocks = 0;
1188
- for (const dir of [hooksDir, claudeDir]) {
1189
- try {
1190
- for (const f of readdirSync(dir)) {
1191
- if (f.endsWith('.lock')) {
1192
- try {
1193
- const st = statSync(join(dir, f));
1194
- if (Date.now() - st.mtimeMs > 30_000) staleLocks++;
1195
- } catch {}
1196
- }
1197
- }
1198
- } catch {}
1199
- }
1200
-
1201
- if (staleLocks > 0) {
1202
- stateIssues.push(`${staleLocks} stale lock file(s)`);
1203
- stateWarns++;
1204
- }
1205
-
1206
- if (stateFails > 0) {
1207
- results.push({ name: 'State files', status: 'fail', detail: stateIssues.join('; '), fix: 'Run: npx dual-brain repair' });
1208
- } else if (stateWarns > 0) {
1209
- results.push({ name: 'State files', status: 'warn', detail: stateIssues.join('; '), fix: 'Run: npx dual-brain repair' });
1210
- } else {
1211
- results.push({ name: 'State files', status: 'pass', detail: `${stateHealthy} file(s) healthy` });
1212
- }
1213
-
1214
- // 5. Error channel
1215
- const errorsFile = join(hooksDir, 'errors.jsonl');
1216
- if (existsSync(errorsFile)) {
1217
- try {
1218
- const raw = readFileSync(errorsFile, 'utf8');
1219
- const lines = raw.split('\n').filter(Boolean);
1220
- const entries = [];
1221
- for (const line of lines) {
1222
- try { entries.push(JSON.parse(line)); } catch {}
1223
- }
1224
-
1225
- const cutoff24h = Date.now() - 24 * 60 * 60 * 1000;
1226
- const recent = entries.filter(e => e.timestamp && Date.parse(e.timestamp) >= cutoff24h);
1227
-
1228
- if (recent.length === 0) {
1229
- results.push({ name: 'Error channel', status: 'pass', detail: 'no errors in last 24h' });
1230
- } else {
1231
- const last3 = recent.slice(-3);
1232
- const detail = `${recent.length} error(s) in last 24h`;
1233
- results.push({ name: 'Error channel', status: 'warn', detail, errors: last3 });
1234
- }
1235
- } catch {
1236
- results.push({ name: 'Error channel', status: 'warn', detail: 'errors.jsonl unreadable' });
1237
- }
1238
- } else {
1239
- results.push({ name: 'Error channel', status: 'pass', detail: 'no errors.jsonl (clean)' });
1240
- }
1241
-
1242
- // Output
1243
- const passCount = results.filter(r => r.status === 'pass').length;
1244
- const warnCount = results.filter(r => r.status === 'warn').length;
1245
- const failCount = results.filter(r => r.status === 'fail').length;
1246
-
1247
- console.log('');
1248
- console.log(` 🩺 dual-brain v${VERSION} — doctor`);
1249
- console.log(' ' + '─'.repeat(50));
1250
-
1251
- for (const r of results) {
1252
- const icon = r.status === 'pass' ? '✓' : r.status === 'warn' ? '⚠' : '✗';
1253
- console.log(` ${icon} ${r.name}: ${r.detail}`);
1254
- if (r.fix) console.log(` → ${r.fix}`);
1255
- if (r.errors) {
1256
- for (const e of r.errors) {
1257
- const ts = e.timestamp ? e.timestamp.slice(11, 19) : '??:??:??';
1258
- console.log(` ${ts} [${e.hook || '?'}] ${e.error || '?'}`);
1259
- }
1260
- }
1261
- }
1262
-
1263
- console.log(' ' + '─'.repeat(50));
1264
- if (failCount > 0) {
1265
- console.log(` ✗ Needs repair: ${failCount} issue(s) require attention`);
1266
- } else if (warnCount > 0) {
1267
- console.log(` ⚠ Warnings: ${warnCount} — system functional but has issues`);
1268
- } else {
1269
- console.log(` ✓ Healthy: all ${passCount} checks passed`);
1270
- }
1271
- console.log('');
1272
- }
1273
-
1274
- // ─── Subcommand: reset ────────────────────────────────────────────────────
1275
-
1276
- function cmdReset() {
1277
- const workspace = resolve(process.cwd());
1278
- const claudeDir = join(workspace, '.claude');
1279
- const hooksDir = join(claudeDir, 'hooks');
1280
-
1281
- // Collect state files (NOT config, hooks, or profile)
1282
- const STATE_FIXED = [
1283
- join(hooksDir, 'usage.jsonl'),
1284
- join(hooksDir, 'decision-ledger.jsonl'),
1285
- join(hooksDir, 'failure-ledger.json'),
1286
- join(hooksDir, '.burst-state'),
1287
- join(hooksDir, 'burst-state.json'),
1288
- join(hooksDir, '.drift-warned'),
1289
- join(hooksDir, '.budget-alerted'),
1290
- join(hooksDir, 'errors.jsonl'),
1291
- join(hooksDir, 'summary-checkpoint.json'),
1292
- join(claudeDir, '.launched'),
1293
- ];
1294
-
1295
- const toDelete = [];
1296
- for (const f of STATE_FIXED) {
1297
- if (existsSync(f)) toDelete.push(f);
1298
- }
1299
-
1300
- // Scan for date-stamped files and lock files
1301
- try {
1302
- for (const f of readdirSync(hooksDir)) {
1303
- if (f.startsWith('usage-') && f.endsWith('.jsonl')) toDelete.push(join(hooksDir, f));
1304
- if (f.startsWith('usage-summary-') && f.endsWith('.json')) toDelete.push(join(hooksDir, f));
1305
- if (f.endsWith('.lock')) toDelete.push(join(hooksDir, f));
1306
- }
1307
- } catch {}
1308
- try {
1309
- for (const f of readdirSync(claudeDir)) {
1310
- if (f.endsWith('.lock')) toDelete.push(join(claudeDir, f));
1311
- }
1312
- } catch {}
1313
-
1314
- const unique = [...new Set(toDelete)].filter(f => existsSync(f));
1315
-
1316
- if (unique.length === 0) {
1317
- console.log('');
1318
- console.log(' 🔄 Nothing to reset — no state files found.');
1319
- console.log('');
1320
- return;
1321
- }
1322
-
1323
- // Require --force or confirmation
1324
- if (!force) {
1325
- console.log('');
1326
- console.log(` 🔄 dual-brain reset — will delete ${unique.length} state file(s):`);
1327
- console.log('');
1328
- for (const f of unique) {
1329
- const rel = f.startsWith(workspace) ? f.slice(workspace.length + 1) : f;
1330
- console.log(` ${rel}`);
1331
- }
1332
- console.log('');
1333
- console.log(' Preserved: orchestrator.json, settings.json, hooks, profile, CLAUDE.md');
1334
- console.log('');
1335
- console.log(` To confirm: ${cmd('npx dual-brain reset --force')}`);
1336
- console.log('');
1337
- return;
1338
- }
1339
-
1340
- let removed = 0;
1341
- const errors = [];
1342
- for (const f of unique) {
1343
- try {
1344
- unlinkSync(f);
1345
- removed++;
1346
- } catch (err) {
1347
- errors.push(` ⚠ Could not remove ${f.split('/').pop()}: ${err.message}`);
1348
- }
1349
- }
1350
-
1351
- console.log('');
1352
- console.log(` 🔄 dual-brain v${VERSION} — reset`);
1353
- console.log(' ' + '─'.repeat(40));
1354
- console.log(` ✓ Removed ${removed} state file(s)`);
1355
- for (const e of errors) console.log(e);
1356
- console.log('');
1357
- console.log(' Preserved: orchestrator.json, settings.json, hooks, profile, CLAUDE.md');
1358
- console.log(' State will rebuild automatically on next session.');
1359
- console.log('');
1360
- }
1361
-
1362
- // ─── Subcommand: repair ───────────────────────────────────────────────────
1363
-
1364
- function cmdRepair() {
1365
- const workspace = resolve(process.cwd());
1366
- const claudeDir = join(workspace, '.claude');
1367
- const hooksDir = join(claudeDir, 'hooks');
1368
- const actions = [];
1369
-
1370
- // 1. Remove stale lock files (older than 30 seconds)
1371
- let staleLocks = 0;
1372
- for (const dir of [hooksDir, claudeDir]) {
1373
- try {
1374
- for (const f of readdirSync(dir)) {
1375
- if (!f.endsWith('.lock')) continue;
1376
- const lockPath = join(dir, f);
1377
- try {
1378
- const st = statSync(lockPath);
1379
- if (Date.now() - st.mtimeMs > 30_000) {
1380
- unlinkSync(lockPath);
1381
- staleLocks++;
1382
- }
1383
- } catch {}
1384
- }
1385
- } catch {}
1386
- }
1387
- if (staleLocks > 0) {
1388
- actions.push(`✓ Removed ${staleLocks} stale lock file(s)`);
1389
- } else {
1390
- actions.push('⊘ No stale locks found');
1391
- }
1392
-
1393
- // 2. Repair corrupt JSONL files
1394
- const jsonlFiles = [
1395
- join(hooksDir, 'decision-ledger.jsonl'),
1396
- join(hooksDir, 'errors.jsonl'),
1397
- join(hooksDir, 'usage.jsonl'),
1398
- ];
1399
- try {
1400
- for (const f of readdirSync(hooksDir)) {
1401
- if (f.startsWith('usage-') && f.endsWith('.jsonl')) {
1402
- jsonlFiles.push(join(hooksDir, f));
1403
- }
1404
- }
1405
- } catch {}
1406
-
1407
- let repairedJsonl = 0;
1408
- for (const filePath of [...new Set(jsonlFiles)]) {
1409
- if (!existsSync(filePath)) continue;
1410
- try {
1411
- const raw = readFileSync(filePath, 'utf8');
1412
- const lines = raw.split('\n').filter(Boolean);
1413
- let badCount = 0;
1414
- const goodLines = [];
1415
- for (const line of lines) {
1416
- try {
1417
- JSON.parse(line);
1418
- goodLines.push(line);
1419
- } catch {
1420
- badCount++;
1421
- }
1422
- }
1423
- if (badCount > 0) {
1424
- const tmp = filePath + '.tmp.' + process.pid;
1425
- writeFileSync(tmp, goodLines.length > 0 ? goodLines.join('\n') + '\n' : '');
1426
- renameSync(tmp, filePath);
1427
- repairedJsonl++;
1428
- actions.push(`✓ ${filePath.split('/').pop()}: removed ${badCount} corrupt line(s), kept ${goodLines.length}`);
1429
- }
1430
- } catch (err) {
1431
- actions.push(`⚠ ${filePath.split('/').pop()}: could not repair (${err.message})`);
1432
- }
1433
- }
1434
- if (repairedJsonl === 0) {
1435
- actions.push('⊘ No corrupt JSONL files found');
1436
- }
1437
-
1438
- // 3. Re-validate and fix orchestrator.json formatting
1439
- const orchPath = join(claudeDir, 'orchestrator.json');
1440
- if (existsSync(orchPath)) {
1441
- try {
1442
- const raw = readFileSync(orchPath, 'utf8');
1443
- const config = JSON.parse(raw);
1444
- const pretty = JSON.stringify(config, null, 2) + '\n';
1445
- if (raw !== pretty) {
1446
- const tmp = orchPath + '.tmp.' + process.pid;
1447
- writeFileSync(tmp, pretty);
1448
- renameSync(tmp, orchPath);
1449
- actions.push('✓ orchestrator.json: re-formatted');
1450
- } else {
1451
- actions.push('⊘ orchestrator.json: already well-formatted');
1452
- }
1453
- } catch (err) {
1454
- actions.push(`✗ orchestrator.json: invalid JSON — ${err.message}`);
1455
- actions.push(' → Run: npx dual-brain --force (to regenerate from template)');
1456
- }
1457
- } else {
1458
- actions.push('✗ orchestrator.json: not found — run: npx dual-brain');
1459
- }
1460
-
1461
- // 4. Re-run hook registration (ensure hooks are in settings.json)
1462
- if (existsSync(join(claudeDir, 'settings.json')) || existsSync(orchPath)) {
1463
- try {
1464
- const settings = generateSettings(workspace);
1465
- const tmp = join(claudeDir, 'settings.json.tmp.' + process.pid);
1466
- writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
1467
- renameSync(tmp, join(claudeDir, 'settings.json'));
1468
- actions.push('✓ settings.json: hooks re-registered');
1469
- } catch (err) {
1470
- actions.push(`⚠ settings.json: could not re-register hooks (${err.message})`);
1471
- }
1472
- }
1473
-
1474
- // Print report
1475
- console.log('');
1476
- console.log(` 🔧 dual-brain v${VERSION} — repair`);
1477
- console.log(' ' + '─'.repeat(40));
1478
- for (const a of actions) {
1479
- console.log(` ${a}`);
1480
- }
1481
- console.log('');
1482
- }
1483
-
1484
- // ─── Uninstall ─────────────────────────────────────────────────────────────
1485
-
1486
- function cmdUninstall() {
1487
- const workspace = resolve(process.cwd());
1488
- const claudeDir = join(workspace, '.claude');
1489
- const hooksDir = join(claudeDir, 'hooks');
1490
- const actions = [];
1491
-
1492
- // 1. Remove dual-brain hooks from settings.json
1493
- const settingsPath = join(claudeDir, 'settings.json');
1494
- if (existsSync(settingsPath)) {
1495
- try {
1496
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1497
- const DUAL_BRAIN_CMDS = [
1498
- 'node .claude/hooks/enforce-tier.mjs',
1499
- 'node .claude/hooks/cost-logger.mjs',
1500
- ];
1501
-
1502
- if (settings.hooks) {
1503
- let removedCount = 0;
1504
- for (const event of Object.keys(settings.hooks)) {
1505
- const before = settings.hooks[event].length;
1506
- settings.hooks[event] = settings.hooks[event].filter(entry =>
1507
- !entry.hooks?.some(h => DUAL_BRAIN_CMDS.includes(h.command))
1508
- );
1509
- removedCount += before - settings.hooks[event].length;
1510
-
1511
- // Clean up empty arrays
1512
- if (settings.hooks[event].length === 0) {
1513
- delete settings.hooks[event];
1514
- }
1515
- }
1516
-
1517
- // Clean up empty hooks object
1518
- if (Object.keys(settings.hooks).length === 0) {
1519
- delete settings.hooks;
1520
- }
1521
-
1522
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1523
- if (removedCount > 0) {
1524
- actions.push(`✓ Removed ${removedCount} hook(s) from settings.json`);
1525
- } else {
1526
- actions.push('⊘ No dual-brain hooks found in settings.json');
1527
- }
1528
- } else {
1529
- actions.push('⊘ No hooks section in settings.json');
1530
- }
1531
- } catch (err) {
1532
- actions.push(`⚠ Could not parse settings.json: ${err.message}`);
1533
- }
1534
- } else {
1535
- actions.push('⊘ No settings.json found');
1536
- }
1537
-
1538
- // 2. Remove state files
1539
- const stateFiles = [
1540
- join(claudeDir, 'dual-brain.profile.json'),
1541
- join(claudeDir, 'dual-brain.memory.json'),
1542
- join(claudeDir, '.launched'),
1543
- ];
1544
-
1545
- // Add date-stamped usage files and summary files
1546
- const today = new Date().toISOString().slice(0, 10);
1547
- stateFiles.push(join(hooksDir, 'usage.jsonl'));
1548
- stateFiles.push(join(hooksDir, `usage-${today}.jsonl`));
1549
- stateFiles.push(join(hooksDir, 'decision-ledger.jsonl'));
1550
- stateFiles.push(join(hooksDir, '.drift-warned'));
1551
- stateFiles.push(join(hooksDir, '.budget-alerted'));
1552
-
1553
- // Scan for any usage-*.jsonl and usage-summary-*.json files
1554
- try {
1555
- const files = readdirSync(hooksDir);
1556
- for (const f of files) {
1557
- if (f.startsWith('usage-') && f.endsWith('.jsonl')) {
1558
- stateFiles.push(join(hooksDir, f));
1559
- }
1560
- if (f.startsWith('usage-summary-') && f.endsWith('.json')) {
1561
- stateFiles.push(join(hooksDir, f));
1562
- }
1563
- if (f === 'burst-state.json' || f === 'failure-ledger.json') {
1564
- stateFiles.push(join(hooksDir, f));
1565
- }
1566
- }
1567
- } catch {}
1568
-
1569
- // Deduplicate
1570
- const uniqueFiles = [...new Set(stateFiles)];
1571
-
1572
- let removedFiles = 0;
1573
- for (const f of uniqueFiles) {
1574
- try {
1575
- if (existsSync(f)) {
1576
- unlinkSync(f);
1577
- removedFiles++;
1578
- }
1579
- } catch {}
1580
- }
1581
-
1582
- if (removedFiles > 0) {
1583
- actions.push(`✓ Removed ${removedFiles} state file(s)`);
1584
- } else {
1585
- actions.push('⊘ No state files to remove');
1586
- }
1587
-
1588
- // 3. Print summary
1589
- console.log('');
1590
- console.log(` 🧠 dual-brain v${VERSION} — uninstall`);
1591
- console.log(' ' + '─'.repeat(40));
1592
- for (const a of actions) {
1593
- console.log(` ${a}`);
1594
- }
1595
- console.log('');
1596
- console.log(' Hook scripts in .claude/hooks/ were left in place');
1597
- console.log(' (they are part of the npm package, not your repo).');
1598
- console.log('');
1599
- console.log(' To reinstall: npx -y dual-brain');
1600
- console.log('');
1601
- }
1602
-
1603
- // ─── Hook Delegation ───────────────────────────────────────────────────────
1604
-
1605
- const HOOK_COMMANDS = {
1606
- review: 'dual-brain-review.mjs',
1607
- think: 'dual-brain-think.mjs',
1608
- health: 'health-check.mjs',
1609
- report: 'session-report.mjs',
1610
- gate: 'quality-gate.mjs',
1611
- vibe: 'vibe-router.mjs',
1612
- plan: 'plan-generator.mjs',
1613
- cost: 'cost-report.mjs',
1614
- dispatch: 'gpt-work-dispatcher.mjs',
1615
- memory: 'vibe-memory.mjs',
1616
- test: 'test-orchestrator.mjs',
1617
- ledger: 'decision-ledger.mjs',
1618
- chains: 'agent-chains.mjs',
1619
- };
1620
-
1621
- function resolveHookScript(hookFile) {
1622
- const workspace = resolve(process.cwd());
1623
- const installed = join(workspace, '.claude', 'hooks', hookFile);
1624
- const bundled = join(__dirname, 'hooks', hookFile);
1625
- return existsSync(installed) ? installed : existsSync(bundled) ? bundled : null;
1626
- }
1627
-
1628
- function delegateToHook(hookFile) {
1629
- const script = resolveHookScript(hookFile);
1630
-
1631
- if (!script) {
1632
- console.error(` Hook not found: ${hookFile}`);
1633
- console.error(` Run: ${cmd('npx dual-brain')} to install hooks first.`);
1634
- process.exit(1);
1635
- }
1636
-
1637
- // Pass through all args after the subcommand
1638
- const extraArgs = process.argv.slice(3);
1639
- const { status } = spawnSync(process.execPath, [script, ...extraArgs], {
1640
- stdio: 'inherit',
1641
- cwd: resolve(process.cwd()),
1642
- });
1643
- process.exit(status || 0);
1644
- }
1645
-
1646
- function delegateToHookWithArgs(hookFile, args) {
1647
- const script = resolveHookScript(hookFile);
1648
-
1649
- if (!script) {
1650
- console.error(` Hook not found: ${hookFile}`);
1651
- console.error(` Run: ${cmd('npx dual-brain')} to install hooks first.`);
1652
- process.exit(1);
1653
- }
1654
-
1655
- const { status } = spawnSync(process.execPath, [script, ...args], {
1656
- stdio: 'inherit',
1657
- cwd: resolve(process.cwd()),
1658
- });
1659
- process.exit(status || 0);
1660
- }
1661
-
1662
- // ─── Main ───────────────────────────────────────────────────────────────────
1663
-
1664
- // ─── Replit-Tools Check ────────────────────────────────────────────────────
1665
-
1666
- async function checkReplitTools() {
1667
- // Only run this check when actually on Replit
1668
- if (!IS_REPLIT) return;
1669
-
1670
- // Check if replit-tools is installed
1671
- const hasReplitTools = existsSync(resolve(process.cwd(), '.replit-tools'));
1672
- if (hasReplitTools) return;
1673
-
1674
- const yesFlag = flag('--yes') || flag('-y');
1675
-
1676
- console.log('');
1677
- console.log(' ━━━ replit-tools Required ━━━');
1678
- console.log('');
1679
- console.log(' dual-brain is built on data-tools/replit-tools by Steve Moraco.');
1680
- console.log(' replit-tools provides persistent auth, session management, and');
1681
- console.log(' container survival that dual-brain depends on.');
1682
- console.log('');
1683
-
1684
- let install = yesFlag;
1685
-
1686
- if (!install) {
1687
- process.stdout.write(' Install now? [Y/n] ');
1688
-
1689
- if (process.stdin.isTTY) {
1690
- install = await new Promise((resolve) => {
1691
- process.stdin.setEncoding('utf8');
1692
- process.stdin.once('data', (chunk) => {
1693
- const answer = chunk.trim().toLowerCase();
1694
- // Default yes: empty input (just Enter) or explicit y/yes
1695
- resolve(answer === '' || answer === 'y' || answer === 'yes');
1696
- });
1697
- process.stdin.resume();
1698
- });
1699
- process.stdin.pause();
1700
- } else {
1701
- // Non-TTY: default to yes
1702
- console.log('');
1703
- install = true;
1704
- }
1705
- }
1294
+ // Restore npm token if missing (for publish access)
1295
+ restoreNpmToken();
1706
1296
 
1707
- console.log('');
1297
+ let env = detectEnvironment();
1298
+ const startupUpdateInfo = (subcommand === 'update' || dryRun || jsonOut)
1299
+ ? null
1300
+ : checkForUpdate(env.workspace);
1708
1301
 
1709
- if (install) {
1710
- console.log(' Installing replit-tools...');
1302
+ if (
1303
+ startupUpdateInfo?.updateAvailable &&
1304
+ process.stdin.isTTY &&
1305
+ process.stdout.isTTY &&
1306
+ !process.env.CI
1307
+ ) {
1711
1308
  console.log('');
1712
- const result = spawnSync('npx', ['-y', 'replit-tools'], { stdio: 'inherit', shell: true });
1309
+ const shouldUpdate = await promptForUpdate(startupUpdateInfo);
1713
1310
  console.log('');
1714
- if (result.status !== 0) {
1715
- console.log(' ⚠ replit-tools install exited with a non-zero status.');
1716
- console.log(' Continuing with dual-brain install anyway.');
1717
- console.log('');
1718
- } else {
1719
- // Re-check that replit-tools is now present
1720
- if (existsSync(resolve(process.cwd(), '.replit-tools'))) {
1721
- console.log(' ✓ replit-tools installed successfully.');
1722
- } else {
1723
- console.log(' replit-tools may need a shell reload. Continuing with dual-brain install.');
1311
+ if (shouldUpdate) {
1312
+ env = healClaudeAuth(env);
1313
+ env = healCodexAuth(env);
1314
+ const updateMode = resolveMode(env);
1315
+ const updateActions = performUpdate(env.workspace, env, updateMode);
1316
+ printReport(env, updateMode, updateActions);
1317
+ if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
1318
+ launchPanel();
1724
1319
  }
1725
- console.log('');
1726
- }
1727
- } else {
1728
- console.log(' dual-brain will work with reduced functionality. Some features');
1729
- console.log(' (persistent state, session resume, auth survival) require replit-tools.');
1730
- console.log('');
1731
- }
1732
- }
1733
-
1734
- // ─── Auth Command ──────────────────────────────────────────────────────────
1735
-
1736
- async function cmdAuth() {
1737
- console.log('');
1738
- console.log(` ${BRAND} — Auth Status`);
1739
- console.log(' ──────────────────────────────────────────────────');
1740
-
1741
- const claude = getClaudeAuthStatus();
1742
- if (claude.valid) {
1743
- const email = claude.email ? ` — ${claude.email}` : '';
1744
- const sub = claude.subscription ? ` (${claude.subscription})` : '';
1745
- console.log(` ✓ Claude: authenticated (${Math.round(claude.expiresInHours)}h remaining)${email}${sub}`);
1746
- } else if (claude.hasRefreshToken) {
1747
- console.log(' ⟳ Claude: expired — attempting refresh...');
1748
- const r = await refreshClaudeToken(claude.credPath);
1749
- console.log(r.success ? ` ✓ Claude: refreshed (${Math.round(r.expiresInHours)}h remaining)` : ' ✗ Claude: refresh failed — run: claude login');
1750
- } else {
1751
- console.log(` ✗ Claude: ${claude.credPath ? 'expired' : 'not configured'} — run: claude login`);
1752
- }
1753
-
1754
- const codex = getCodexAuthStatus();
1755
- if (codex.valid) {
1756
- const email = codex.email ? ` — ${codex.email}` : '';
1757
- console.log(` ✓ Codex: authenticated (${Math.round(codex.expiresInHours)}h remaining)${email}`);
1758
- } else if (codex.hasRefreshToken) {
1759
- console.log(' ⟳ Codex: expired — attempting refresh...');
1760
- const r = await refreshCodexToken();
1761
- console.log(r.success ? ` ✓ Codex: refreshed (${Math.round(r.expiresInHours)}h remaining)` : ' ✗ Codex: refresh failed — run: codex login');
1762
- } else {
1763
- console.log(' ✗ Codex: not configured — run: codex login');
1764
- console.log(' (GPT lane will be disabled)');
1765
- }
1766
-
1767
- if (IS_REPLIT) {
1768
- console.log('');
1769
- console.log(' Replit tip: If login shows localhost errors, use browser-based auth');
1770
- console.log(' or run the login command from a local machine and copy credentials.');
1771
- }
1772
- console.log('');
1773
- }
1774
-
1775
- // ─── Preflight Auth ────────────────────────────────────────────────────────
1776
-
1777
- async function preflightAuth() {
1778
- const claude = getClaudeAuthStatus();
1779
- const codex = getCodexAuthStatus();
1780
- let claudeOk = claude.valid;
1781
- let codexOk = codex.valid;
1782
-
1783
- if (!claudeOk && claude.hasRefreshToken) {
1784
- const r = await refreshClaudeToken(claude.credPath);
1785
- claudeOk = r.success;
1786
- if (r.success) console.log(` ⟳ Claude auth refreshed (${Math.round(r.expiresInHours)}h)`);
1787
- }
1788
- if (!codexOk && codex.hasRefreshToken) {
1789
- const r = await refreshCodexToken();
1790
- codexOk = r.success;
1791
- if (r.success) console.log(` ⟳ Codex auth refreshed (${Math.round(r.expiresInHours)}h)`);
1792
- }
1793
-
1794
- if (!claudeOk) {
1795
- console.log('');
1796
- console.log(' ✗ Claude auth required. Run: claude login');
1797
- if (IS_REPLIT) console.log(' (Use browser flow — localhost callbacks may not work)');
1798
- console.log('');
1799
- return false;
1800
- }
1801
- if (!codexOk) console.log(' ⚠ Codex not authenticated — GPT lane disabled (Claude-only mode)');
1802
- return true;
1803
- }
1804
-
1805
- // ─── Session Wizard ────────────────────────────────────────────────────────
1806
-
1807
- function recommendModel() {
1808
- const claude = getClaudeAuthStatus();
1809
- const codex = getCodexAuthStatus();
1810
- if (claude.subscription === 'max' && codex.valid) return { model: 'sonnet', reason: 'Both $100 subs — sonnet for chat, opus auto-escalates for thinking' };
1811
- if (claude.subscription === 'max') return { model: 'sonnet', reason: '$100 Claude Max — sonnet balances speed + quality' };
1812
- if (claude.subscription === 'pro') return { model: 'haiku', reason: '$20 Claude Pro — haiku conserves limited credits' };
1813
- return { model: 'sonnet', reason: 'Default — good balance of speed and capability' };
1814
- }
1815
-
1816
- async function askLine(prompt) {
1817
- if (!process.stdin.isTTY || yesFlag || process.env.CI) return '';
1818
- process.stdout.write(prompt);
1819
- const answer = await new Promise(resolve => {
1820
- process.stdin.setEncoding('utf8');
1821
- process.stdin.once('data', chunk => resolve(chunk.trim().toLowerCase()));
1822
- process.stdin.resume();
1823
- });
1824
- process.stdin.pause();
1825
- return answer;
1826
- }
1827
-
1828
- async function runSessionWizard(workspace) {
1829
- const rec = recommendModel();
1830
- const claude = getClaudeAuthStatus();
1831
- const codex = getCodexAuthStatus();
1832
-
1833
- console.log('');
1834
- console.log(` ${BRAND}`);
1835
- console.log(` ${BRAND_SUBTITLE}`);
1836
- console.log('');
1837
-
1838
- const parts = [];
1839
- if (claude.subscription) parts.push(`Claude ${claude.subscription}`);
1840
- if (codex.valid) parts.push('Codex Pro');
1841
- if (parts.length) {
1842
- console.log(` Detected: ${parts.join(' + ')}`);
1843
- console.log(` Recommendation: ${rec.reason}`);
1844
- console.log('');
1845
- }
1846
-
1847
- console.log(' Head session model:');
1848
- console.log(' [s] Sonnet — fast, cost-effective, great for most work');
1849
- console.log(' [o] Opus — slower, smarter, best for complex architecture');
1850
- console.log(' [h] Haiku — fastest, cheapest, good for simple tasks');
1851
- const modelKey = await askLine(` Choice [${rec.model === 'haiku' ? 'h' : 's'}]: `);
1852
- let model = rec.model === 'haiku' ? 'haiku' : 'sonnet';
1853
- if (modelKey === 'o' || modelKey === 'opus') model = 'opus';
1854
- else if (modelKey === 'h' || modelKey === 'haiku') model = 'haiku';
1855
- else if (modelKey === 's' || modelKey === 'sonnet') model = 'sonnet';
1856
-
1857
- if (model === 'opus' && process.stdin.isTTY) {
1858
- console.log(' ⚠ Opus uses ~5x more credits than Sonnet.');
1859
- const confirm = await askLine(' Continue with Opus? [y/N] ');
1860
- if (confirm !== 'y' && confirm !== 'yes') { model = 'sonnet'; console.log(' Switched to Sonnet.'); }
1861
- }
1862
-
1863
- console.log('');
1864
- console.log(' Effort level:');
1865
- console.log(' [l] Low — faster responses, less thorough');
1866
- console.log(' [m] Medium — balanced (default)');
1867
- console.log(' [h] High — thorough, slower, more expensive');
1868
- const effortKey = await askLine(' Choice [m]: ');
1869
- let effort = 'medium';
1870
- if (effortKey === 'l' || effortKey === 'low') effort = 'low';
1871
- else if (effortKey === 'h' || effortKey === 'high') effort = 'high';
1872
-
1873
- const prefs = { head_model: model, effort, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
1874
- saveSessionPrefs(workspace, prefs);
1875
- console.log('');
1876
- console.log(` Saved: ${model} / ${effort} effort`);
1877
- console.log(' Change anytime: npx dual-brain start --model opus --effort high');
1878
- return prefs;
1879
- }
1880
-
1881
- // ─── Session Launcher ──────────────────────────────────────────────────────
1882
-
1883
- function launchClaudeSession(workspace, prefs) {
1884
- const model = prefs.head_model || 'sonnet';
1885
- const args = [];
1886
- const helpOut = run('claude', ['--help']);
1887
- const helpText = (helpOut.stdout || '') + (helpOut.stderr || '');
1888
- if (helpText.includes('--model')) args.push('--model', model);
1889
- if (helpText.includes('--resume') || helpText.includes('-c')) args.push('-c');
1890
-
1891
- console.log('');
1892
- console.log(` Launching ${model} session...`);
1893
- console.log(' (Work agents auto-route: haiku/sonnet/opus by task tier)');
1894
- console.log('');
1895
-
1896
- const result = spawnSync('claude', args, {
1897
- stdio: 'inherit',
1898
- cwd: workspace,
1899
- env: { ...process.env, DUAL_BRAIN_HEAD_MODEL: model, DUAL_BRAIN_HEAD_EFFORT: prefs.effort || 'medium', DUAL_BRAIN_WORKSPACE: workspace },
1900
- });
1901
- process.exit(result.status ?? 0);
1902
- }
1903
-
1904
- // ─── Ensure Setup + Launch ─────────────────────────────────────────────────
1905
-
1906
- async function ensureAndLaunch() {
1907
- const workspace = resolve(process.cwd());
1908
- if (!dryRun && !jsonOut) await checkReplitTools();
1909
-
1910
- const alreadySetUp = existsSync(join(workspace, '.claude', 'hooks'))
1911
- || existsSync(join(workspace, '.claude', 'settings.json'));
1912
- if (!alreadySetUp || force) {
1913
- const env = detectEnvironment();
1914
- const mode = resolveMode(env);
1915
- install(env.workspace, env, mode);
1916
- }
1917
-
1918
- const authOk = await preflightAuth();
1919
- if (!authOk) { console.log(' Fix auth first, then run: npx dual-brain'); process.exit(1); }
1920
-
1921
- const cliModel = argv.find((a, i) => argv[i - 1] === '--model');
1922
- const cliEffort = argv.find((a, i) => argv[i - 1] === '--effort');
1923
- let prefs = loadSessionPrefs(workspace);
1924
-
1925
- if (!prefs.created_at && !noLaunch) prefs = await runSessionWizard(workspace);
1926
- if (cliModel) prefs.head_model = cliModel;
1927
- if (cliEffort) prefs.effort = cliEffort;
1928
-
1929
- if (noLaunch) { printQuickStart(); return; }
1930
- launchClaudeSession(workspace, prefs);
1931
- }
1932
-
1933
- // ─── Doctor with Auth ──────────────────────────────────────────────────────
1934
-
1935
- async function cmdDoctorWithAuth() {
1936
- cmdDoctor();
1937
- console.log(' Auth Status:');
1938
- const claude = getClaudeAuthStatus();
1939
- if (claude.valid) {
1940
- const email = claude.email ? ` — ${claude.email}` : '';
1941
- const sub = claude.subscription ? ` (${claude.subscription})` : '';
1942
- console.log(` ✓ Claude: ${Math.round(claude.expiresInHours)}h remaining${email}${sub}`);
1943
- } else {
1944
- console.log(' ✗ Claude: not authenticated — run: npx dual-brain auth');
1945
- }
1946
- const codex = getCodexAuthStatus();
1947
- if (codex.valid) {
1948
- const email = codex.email ? ` — ${codex.email}` : '';
1949
- console.log(` ✓ Codex: ${Math.round(codex.expiresInHours)}h remaining${email}`);
1950
- } else {
1951
- console.log(' ✗ Codex: not authenticated — run: npx dual-brain auth');
1952
- }
1953
- console.log('');
1954
- }
1955
-
1956
- // ─── Main ───────────────────────────────────────────────────────────────────
1957
-
1958
- async function main() {
1959
- if (flag('--uninstall')) { cmdUninstall(); return; }
1960
-
1961
- if (subcommand === 'status') {
1962
- launchPanel();
1963
- return;
1964
- }
1965
- if (subcommand === 'mode') { cmdMode(); return; }
1966
- if (subcommand === 'budget') { cmdBudget(); return; }
1967
- if (subcommand === 'explain') { cmdExplain(); return; }
1968
- if (subcommand === 'doctor') { await cmdDoctorWithAuth(); return; }
1969
- if (subcommand === 'reset') { cmdReset(); return; }
1970
- if (subcommand === 'repair') { cmdRepair(); return; }
1971
- if (subcommand === 'auth') { await cmdAuth(); return; }
1972
- if (subcommand === 'start' || subcommand === 'chat') {
1973
- await ensureAndLaunch(); return;
1974
- }
1975
-
1976
- // agent <template> [flags] → agent-templates.mjs --run <template> [flags]
1977
- if (subcommand === 'agent') {
1978
- const templateName = positional[1];
1979
- if (!templateName) {
1980
- // No template name — fall through to list
1981
- delegateToHookWithArgs('agent-templates.mjs', ['--list']);
1982
1320
  return;
1983
1321
  }
1984
- const extraFlags = process.argv.slice(4); // skip node, install.mjs, 'agent', <template>
1985
- delegateToHookWithArgs('agent-templates.mjs', ['--run', templateName, ...extraFlags]);
1986
- return;
1987
1322
  }
1988
1323
 
1989
- // agents → agent-templates.mjs --list
1990
- if (subcommand === 'agents') {
1991
- delegateToHookWithArgs('agent-templates.mjs', ['--list']);
1992
- return;
1993
- }
1324
+ // Auth self-healing: try to fix expired/missing auth silently
1325
+ env = healClaudeAuth(env);
1326
+ env = healCodexAuth(env);
1994
1327
 
1995
- // chain <name> [flags] agent-chains.mjs --run <name> [flags]
1996
- if (subcommand === 'chain') {
1997
- const chainName = positional[1];
1998
- if (!chainName) {
1999
- // No chain name given — fall through to list
2000
- delegateToHookWithArgs('agent-chains.mjs', ['--list']);
2001
- return;
2002
- }
2003
- const extraFlags = process.argv.slice(4); // skip node, install.mjs, 'chain', <name>
2004
- delegateToHookWithArgs('agent-chains.mjs', ['--run', chainName, ...extraFlags]);
2005
- return;
2006
- }
2007
-
2008
- // chains → agent-chains.mjs --list
2009
- if (subcommand === 'chains') {
2010
- delegateToHookWithArgs('agent-chains.mjs', ['--list']);
2011
- return;
1328
+ // Interactive auth guidance if still not authed
1329
+ if (!env.claude.authed || !env.codex.authed) {
1330
+ env = await authGuidance(env);
2012
1331
  }
2013
1332
 
2014
- // ─── Ship Captain commands ─────────────────────────────────────────────────
2015
-
2016
- // do "<goal>" → ship-captain.mjs --goal "<goal>" [remaining flags]
2017
- if (subcommand === 'do') {
2018
- const goal = positional[1] || '';
2019
- if (!goal) {
2020
- console.error(' Usage: npx dual-brain do "<goal>"');
2021
- process.exit(1);
2022
- }
2023
- // Collect remaining flags (everything after the goal string)
2024
- const extraFlags = process.argv.slice(4).filter(a => a.startsWith('-'));
2025
- delegateToHookWithArgs('ship-captain.mjs', ['--goal', goal, ...extraFlags]);
2026
- return;
2027
- }
2028
-
2029
- // ship → ship-gate.mjs --ship [remaining flags]
2030
- if (subcommand === 'ship') {
2031
- const extraFlags = process.argv.slice(3).filter(a => a.startsWith('-'));
2032
- delegateToHookWithArgs('ship-gate.mjs', ['--ship', ...extraFlags]);
2033
- return;
2034
- }
2035
-
2036
- // test-run → ship-gate.mjs --test-only
2037
- if (subcommand === 'test-run') {
2038
- const extraFlags = process.argv.slice(3).filter(a => a.startsWith('-'));
2039
- delegateToHookWithArgs('ship-gate.mjs', ['--test-only', ...extraFlags]);
2040
- return;
2041
- }
2042
-
2043
- // diff → ship-gate.mjs --diff-only
2044
- if (subcommand === 'diff') {
2045
- delegateToHookWithArgs('ship-gate.mjs', ['--diff-only']);
2046
- return;
2047
- }
2048
-
2049
- // runs → list recent Ship Captain runs from .claude/runs/
2050
- if (subcommand === 'runs') {
2051
- const workspace = resolve(process.cwd());
2052
- const runsDir = join(workspace, '.claude', 'runs');
2053
- if (!existsSync(runsDir)) {
2054
- console.log('');
2055
- console.log(' No Ship Captain runs found.');
2056
- console.log(` Run: ${cmd('npx dual-brain do "<goal>"')} to start your first run.`);
2057
- console.log('');
2058
- return;
2059
- }
2060
- let files;
2061
- try {
2062
- files = readdirSync(runsDir).filter(f => f.endsWith('.json')).sort().reverse();
2063
- } catch {
2064
- files = [];
2065
- }
2066
- if (files.length === 0) {
2067
- console.log('');
2068
- console.log(' No run records found in .claude/runs/');
2069
- console.log('');
2070
- return;
2071
- }
2072
- const rows = [];
2073
- for (const f of files) {
2074
- try {
2075
- const rec = JSON.parse(readFileSync(join(runsDir, f), 'utf8'));
2076
- const id = rec.id || f.replace('.json', '');
2077
- const goal = (rec.goal || '').slice(0, 35);
2078
- const status = rec.status || '?';
2079
- const steps = Array.isArray(rec.steps) ? rec.steps.length : (rec.step_count || '?');
2080
- const dur = rec.duration_ms != null ? `${(rec.duration_ms / 1000).toFixed(1)}s` : rec.duration || '?';
2081
- const date = rec.started_at ? rec.started_at.slice(0, 16).replace('T', ' ') : (rec.date || '?');
2082
- rows.push({ id, goal, status, steps, dur, date });
2083
- } catch {}
2084
- }
2085
- if (rows.length === 0) {
2086
- console.log(' No readable run records.');
2087
- return;
2088
- }
2089
- const colW = { id: 14, goal: 37, status: 10, steps: 6, dur: 8, date: 16 };
2090
- const header = [
2091
- 'ID'.padEnd(colW.id), 'GOAL'.padEnd(colW.goal), 'STATUS'.padEnd(colW.status),
2092
- 'STEPS'.padStart(colW.steps), 'DUR'.padStart(colW.dur), 'DATE'.padEnd(colW.date),
2093
- ].join(' ');
2094
- console.log('');
2095
- console.log(` Ship Captain Runs (${rows.length})`);
2096
- console.log(' ' + '─'.repeat(header.length));
2097
- console.log(' ' + header);
2098
- console.log(' ' + '─'.repeat(header.length));
2099
- for (const r of rows) {
2100
- const line = [
2101
- String(r.id).padEnd(colW.id), String(r.goal).padEnd(colW.goal),
2102
- String(r.status).padEnd(colW.status), String(r.steps).padStart(colW.steps),
2103
- String(r.dur).padStart(colW.dur), String(r.date).padEnd(colW.date),
2104
- ].join(' ');
2105
- console.log(' ' + line);
2106
- }
2107
- console.log('');
2108
- return;
2109
- }
2110
-
2111
- // resume → find most recent incomplete/failed run, offer to re-execute from the failed step
2112
- if (subcommand === 'resume') {
2113
- const workspace = resolve(process.cwd());
2114
- const runsDir = join(workspace, '.claude', 'runs');
2115
- if (!existsSync(runsDir)) {
2116
- console.log('');
2117
- console.log(" No runs found. Start with: npx dual-brain do '<goal>'");
2118
- console.log('');
2119
- return;
2120
- }
2121
- let files;
2122
- try {
2123
- files = readdirSync(runsDir).filter(f => f.endsWith('.json')).sort().reverse();
2124
- } catch {
2125
- files = [];
2126
- }
2127
- if (files.length === 0) {
2128
- console.log('');
2129
- console.log(" No runs found. Start with: npx dual-brain do '<goal>'");
2130
- console.log('');
2131
- return;
2132
- }
2133
-
2134
- // Load the most recent run record
2135
- let rec = null;
2136
- let recFile = null;
2137
- try {
2138
- recFile = files[0];
2139
- rec = JSON.parse(readFileSync(join(runsDir, recFile), 'utf8'));
2140
- } catch {}
2141
-
2142
- if (!rec) {
2143
- console.log('');
2144
- console.log(" No runs found. Start with: npx dual-brain do '<goal>'");
2145
- console.log('');
2146
- return;
2147
- }
2148
-
2149
- const INCOMPLETE_STATUSES = ['failed', 'error', 'partial', 'running', 'pending', 'incomplete', 'aborted'];
2150
- const steps = Array.isArray(rec.steps) ? rec.steps : [];
2151
- const isCompleted = (rec.status || '').toLowerCase() === 'completed';
2152
- const allDone = steps.length > 0 && steps.every(s =>
2153
- s.status === 'done' || s.status === 'complete' || s.status === 'skipped'
2154
- );
2155
-
2156
- if (isCompleted && (allDone || steps.length === 0)) {
2157
- const goal = rec.goal || '(unknown)';
2158
- console.log('');
2159
- console.log(' Last run completed successfully.');
2160
- console.log(` Start a new one with: npx dual-brain do '${goal}'`);
2161
- console.log('');
2162
- return;
2163
- }
2164
-
2165
- // Run has failed/aborted/incomplete — print summary
2166
- const done = steps.filter(s => s.status === 'done' || s.status === 'complete').length;
2167
- const failedSteps = steps.filter(s => s.status === 'failed' || s.status === 'error');
2168
- const incompleteIdx = steps.findIndex(s =>
2169
- s.status !== 'done' && s.status !== 'complete' && s.status !== 'skipped'
2170
- );
2171
- const resumeFromIdx = incompleteIdx >= 0 ? incompleteIdx : steps.length;
2172
-
2173
- console.log('');
2174
- console.log(' Last Run State');
2175
- console.log(' ' + '─'.repeat(50));
2176
- console.log(` ID: ${rec.id || recFile.replace('.json', '')}`);
2177
- console.log(` Goal: ${rec.goal || '(unknown)'}`);
2178
- console.log(` Status: ${rec.status || '(unknown)'}`);
2179
- if (steps.length > 0) {
2180
- console.log(` Steps: ${done}/${steps.length} done, ${failedSteps.length} failed`);
2181
- const failedStep = failedSteps[0];
2182
- if (failedStep) {
2183
- const fi = steps.indexOf(failedStep);
2184
- const fname = failedStep.task || failedStep.name || failedStep.label || `step ${fi}`;
2185
- console.log(` Failed: ${fname}`);
2186
- if (failedStep.error) console.log(` Error: ${failedStep.error}`);
2187
- }
2188
- }
2189
- if (rec.started_at) console.log(` Started: ${rec.started_at.slice(0, 16).replace('T', ' ')}`);
2190
- if (rec.error) console.log(` Error: ${rec.error}`);
2191
- console.log('');
2192
-
2193
- const isResumable = INCOMPLETE_STATUSES.includes((rec.status || '').toLowerCase()) && rec.goal;
2194
-
2195
- if (!isResumable) {
2196
- console.log(" Use npx dual-brain do '<goal>' to start fresh");
2197
- console.log('');
2198
- return;
2199
- }
2200
-
2201
- const stepNum = resumeFromIdx + 1;
2202
- const totalSteps = steps.length || '?';
2203
- console.log(` Resume from step ${stepNum}/${totalSteps}? [Y/n]`);
2204
-
2205
- const yesFlag = flag('--yes') || flag('-y');
2206
-
2207
- if (!yesFlag && process.stdin.isTTY) {
2208
- const { createInterface } = await import('readline');
2209
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2210
- const answer = await new Promise((res) => {
2211
- rl.question(' > ', (a) => { rl.close(); res(a.trim()); });
2212
- });
2213
- if (answer !== '' && !/^y(es)?$/i.test(answer)) {
2214
- console.log('');
2215
- console.log(` Use npx dual-brain do '${rec.goal}' to start fresh`);
2216
- console.log('');
2217
- return;
2218
- }
2219
- }
2220
-
2221
- // Reconstruct flags from stored options in the run record
2222
- const storedOpts = rec.options || {};
2223
-
2224
- // Dynamically import and invoke ship-captain's executeShipCaptain
2225
- const captainPath = resolveHookScript('ship-captain.mjs');
2226
- if (!captainPath) {
2227
- console.error(' ship-captain.mjs not found. Run: npx dual-brain to reinstall hooks.');
2228
- process.exit(1);
2229
- }
2230
- const { executeShipCaptain } = await import(captainPath);
2231
- await executeShipCaptain(rec.goal, {
2232
- yes: yesFlag || storedOpts.yes || false,
2233
- yolo: storedOpts.yolo || false,
2234
- careful: storedOpts.careful || false,
2235
- noPr: storedOpts.noPr || false,
2236
- mode: storedOpts.mode || null,
2237
- resumeFrom: resumeFromIdx,
2238
- resumedFromId: rec.id,
2239
- });
2240
- return;
2241
- }
2242
-
2243
- // Delegate hook-backed commands
2244
- if (subcommand && HOOK_COMMANDS[subcommand]) {
2245
- delegateToHook(HOOK_COMMANDS[subcommand]);
2246
- return;
2247
- }
2248
-
2249
- // ── Bare invocation (no subcommand): setup if needed, then launch ──
2250
- if (!subcommand) {
2251
- await ensureAndLaunch();
2252
- return;
2253
- }
2254
-
2255
- if (subcommand === 'setup') {
2256
- if (!dryRun && !jsonOut) await checkReplitTools();
2257
- const env = detectEnvironment();
2258
- const mode = resolveMode(env);
2259
- const needsGuidance = printGuidedAuth(env);
2260
- if (needsGuidance && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
2261
- await waitForAuth(env);
2262
- Object.assign(mode, resolveMode(env));
2263
- }
2264
- const actions = install(env.workspace, env, mode);
2265
- printReport(env, mode, actions);
2266
- printQuickStart();
2267
- return;
2268
- }
2269
-
2270
- // ── replit-tools check — first thing before auth detection or install ──
2271
- if (!dryRun && !jsonOut) {
2272
- await checkReplitTools();
2273
- }
2274
-
2275
- const env = detectEnvironment();
1333
+ // Re-resolve mode after healing
2276
1334
  const mode = resolveMode(env);
2277
1335
 
2278
1336
  if (dryRun || jsonOut) {
@@ -2284,17 +1342,30 @@ async function main() {
2284
1342
  process.exit(0);
2285
1343
  }
2286
1344
 
2287
- // Guided auth: print instructions if a provider is missing/not authed
2288
- // and offer to wait (interactive only)
2289
- const needsGuidance = printGuidedAuth(env);
2290
- if (needsGuidance && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
2291
- await waitForAuth(env);
2292
- Object.assign(mode, resolveMode(env));
1345
+ if (subcommand === 'update') {
1346
+ const actions = performUpdate(env.workspace, env, mode);
1347
+ printReport(env, mode, actions);
1348
+ process.exit(0);
1349
+ }
1350
+
1351
+ // Check for replit-tools on Replit
1352
+ if (env.isReplit && !env.hasReplitTools) {
1353
+ console.log('');
1354
+ console.log(' ⚠️ replit-tools not found — recommended for Replit environments.');
1355
+ console.log(' Dual-brain works best alongside replit-tools for persistent auth,');
1356
+ console.log(' session management, and shell integration.');
1357
+ console.log('');
1358
+ console.log(` Install: ${cmd('npx -y data-tools')}`);
1359
+ console.log('');
2293
1360
  }
2294
1361
 
2295
1362
  const actions = install(env.workspace, env, mode);
2296
1363
  printReport(env, mode, actions);
2297
- printQuickStart();
1364
+
1365
+ // After install, launch the session manager (interactive TTY only)
1366
+ if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
1367
+ launchPanel();
1368
+ }
2298
1369
  }
2299
1370
 
2300
1371
  main();