dual-brain 4.8.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +19 -0
- package/hooks/control-panel.mjs +126 -19
- package/hooks/dual-brain-review.mjs +3 -1
- package/hooks/dual-brain-think.mjs +3 -1
- package/hooks/enforce-tier.mjs +8 -4
- package/hooks/gpt-work-dispatcher.mjs +114 -47
- package/hooks/health-check.mjs +46 -3
- package/hooks/quality-gate.mjs +7 -0
- package/install.mjs +67 -5
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -43,6 +43,25 @@ Dual-brain is a multi-round conversation between Claude and GPT — not a single
|
|
|
43
43
|
3. High-risk decisions → dual-brain think
|
|
44
44
|
4. When a task spans tiers: think > execute > search
|
|
45
45
|
|
|
46
|
+
## Mandatory Workload Distribution
|
|
47
|
+
|
|
48
|
+
**Claude MUST follow these rules before implementing multi-file changes:**
|
|
49
|
+
|
|
50
|
+
1. **Before starting any batch of 3+ file edits**: run `node .claude/hooks/budget-balancer.mjs` and `node .claude/hooks/vibe-router.mjs "description"` to check provider balance and classify tasks
|
|
51
|
+
2. **When budget-balancer recommends GPT**: dispatch execution work via `node .claude/hooks/gpt-work-dispatcher.mjs --task "..." --tier execute`
|
|
52
|
+
3. **Security/auth/credential changes**: always require dual-brain think flow before implementation
|
|
53
|
+
4. **Audit remediation batches**: plan waves with dual-brain think, dispatch execution to GPT, Claude reviews
|
|
54
|
+
5. **Claude's role in multi-task work**: define acceptance criteria, dispatch agents, review results — not solo-implement everything
|
|
55
|
+
|
|
56
|
+
**Triggers that require this workflow:**
|
|
57
|
+
- 3+ production files being edited in one session
|
|
58
|
+
- Any change touching auth, credentials, tokens, or secrets
|
|
59
|
+
- Any change to dispatcher, agent routing, or tier logic
|
|
60
|
+
- Audit or review remediation involving multiple subsystems
|
|
61
|
+
- When Claude's think capacity is above 60% per budget-balancer
|
|
62
|
+
|
|
63
|
+
**Failure to route is itself a bug.** If Claude implements a large batch solo when GPT has capacity, the user should treat this as a process failure and correct it.
|
|
64
|
+
|
|
46
65
|
## Quality Gate
|
|
47
66
|
|
|
48
67
|
Before ending a session with code changes:
|
package/hooks/control-panel.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { spawnSync } from 'child_process';
|
|
|
15
15
|
|
|
16
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const PROFILE_FILE = join(__dirname, '..', 'dual-brain.profile.json');
|
|
18
|
+
const PERMISSIONS_FILE = join(__dirname, '..', 'dual-brain.permissions.json');
|
|
18
19
|
const LAUNCHED_MARKER = join(__dirname, '..', '.launched');
|
|
19
20
|
const VERSION_STAMP_FILE = join(__dirname, '..', 'dual-brain.version.json');
|
|
20
21
|
const UPDATE_CACHE_FILE = join(__dirname, '..', 'dual-brain.update-check.json');
|
|
@@ -56,6 +57,32 @@ function writeJsonFile(path, value) {
|
|
|
56
57
|
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function loadPermissions() {
|
|
61
|
+
const defaults = {
|
|
62
|
+
claude_skip_permissions: false,
|
|
63
|
+
codex_bypass_sandbox: false,
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(readFileSync(PERMISSIONS_FILE, 'utf8'));
|
|
67
|
+
return {
|
|
68
|
+
claude_skip_permissions: !!data.claude_skip_permissions,
|
|
69
|
+
codex_bypass_sandbox: !!data.codex_bypass_sandbox,
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return defaults;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function savePermissions(perms) {
|
|
77
|
+
const next = {
|
|
78
|
+
claude_skip_permissions: !!perms.claude_skip_permissions,
|
|
79
|
+
codex_bypass_sandbox: !!perms.codex_bypass_sandbox,
|
|
80
|
+
};
|
|
81
|
+
const tmp = PERMISSIONS_FILE + '.tmp.' + process.pid;
|
|
82
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
|
|
83
|
+
renameSync(tmp, PERMISSIONS_FILE);
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
function compareVersions(a, b) {
|
|
60
87
|
const aParts = String(a || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
61
88
|
const bParts = String(b || '').replace(/^v/i, '').split('.').map(n => parseInt(n, 10) || 0);
|
|
@@ -468,8 +495,14 @@ async function showAuthMenu(rl, providers) {
|
|
|
468
495
|
|
|
469
496
|
if (choice === 'j') {
|
|
470
497
|
console.log('');
|
|
471
|
-
spawnSync('claude', ['login'], { stdio: 'inherit' });
|
|
472
|
-
|
|
498
|
+
const r = spawnSync('claude', ['login'], { stdio: 'inherit' });
|
|
499
|
+
const fresh = detectProviders();
|
|
500
|
+
providers.claude = fresh.claude;
|
|
501
|
+
if (providers.claude.authed) {
|
|
502
|
+
console.log(` ${green('Claude authenticated.')}`);
|
|
503
|
+
} else {
|
|
504
|
+
console.log(` ${yellow('Claude login did not complete.')} Try again or check your subscription.`);
|
|
505
|
+
}
|
|
473
506
|
continue;
|
|
474
507
|
}
|
|
475
508
|
if (choice === 'k' && providers.codex.installed) {
|
|
@@ -479,7 +512,13 @@ async function showAuthMenu(rl, providers) {
|
|
|
479
512
|
console.log(` Open: ${cyan('https://auth.openai.com/codex/device')}`);
|
|
480
513
|
console.log('');
|
|
481
514
|
spawnSync(codexPath.stdout.trim(), ['login', '--device-auth'], { stdio: 'inherit' });
|
|
482
|
-
|
|
515
|
+
const fresh = detectProviders();
|
|
516
|
+
providers.codex = fresh.codex;
|
|
517
|
+
if (providers.codex.authed) {
|
|
518
|
+
console.log(` ${green('Codex authenticated.')}`);
|
|
519
|
+
} else {
|
|
520
|
+
console.log(` ${yellow('Codex login did not complete.')} Try again.`);
|
|
521
|
+
}
|
|
483
522
|
}
|
|
484
523
|
continue;
|
|
485
524
|
}
|
|
@@ -623,9 +662,9 @@ function renderFirstRunMenu(providers) {
|
|
|
623
662
|
lines.push('');
|
|
624
663
|
|
|
625
664
|
// Provider status
|
|
626
|
-
const cStat = providers.claude.authed ? '✅' : providers.claude.installed ? '⚠️' : '❌';
|
|
627
|
-
const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
|
|
628
|
-
lines.push(` 🟠 Claude ${cStat} 🟢 Codex ${xStat}`);
|
|
665
|
+
const cStat = providers.claude.authed ? (noColor ? '[OK]' : '✅') : providers.claude.installed ? (noColor ? '[!]' : '⚠️') : (noColor ? '[X]' : '❌');
|
|
666
|
+
const xStat = providers.codex.authed ? (noColor ? '[OK]' : '✅') : providers.codex.installed ? (noColor ? '[!]' : '⚠️') : (noColor ? '[X]' : '❌');
|
|
667
|
+
lines.push(` ${noColor ? '' : '🟠 '}Claude ${cStat} ${noColor ? '' : '🟢 '}Codex ${xStat}`);
|
|
629
668
|
|
|
630
669
|
if (providers.claude.authed && providers.codex.authed) {
|
|
631
670
|
lines.push(` ${green('Both providers ready — full dual-brain mode')}`);
|
|
@@ -667,7 +706,8 @@ function renderFirstRunMenu(providers) {
|
|
|
667
706
|
lines.push(` ${bold('[a]')} Auth management`);
|
|
668
707
|
lines.push(` ${bold('[d]')} Dashboard & diagnostics`);
|
|
669
708
|
lines.push(` ${bold('[s]')} Skip — just shell`);
|
|
670
|
-
lines.push(` ${
|
|
709
|
+
lines.push(` ${dim('Enter = new session · [?] help')}`);
|
|
710
|
+
|
|
671
711
|
lines.push('');
|
|
672
712
|
|
|
673
713
|
return lines;
|
|
@@ -675,6 +715,7 @@ function renderFirstRunMenu(providers) {
|
|
|
675
715
|
|
|
676
716
|
function renderReturningMenu(providers, sessions) {
|
|
677
717
|
const profile = loadProfile();
|
|
718
|
+
const permissions = loadPermissions();
|
|
678
719
|
const pf = PROFILES[profile.name];
|
|
679
720
|
const running = countRunning();
|
|
680
721
|
const balance = loadProviderBalance();
|
|
@@ -688,8 +729,8 @@ function renderReturningMenu(providers, sessions) {
|
|
|
688
729
|
lines.push('');
|
|
689
730
|
|
|
690
731
|
// Provider status
|
|
691
|
-
const cStat = providers.claude.authed ? '✅' : '⚠️';
|
|
692
|
-
const xStat = providers.codex.authed ? '✅' : providers.codex.installed ? '⚠️' : '❌';
|
|
732
|
+
const cStat = providers.claude.authed ? (noColor ? '[OK]' : '✅') : (noColor ? '[!]' : '⚠️');
|
|
733
|
+
const xStat = providers.codex.authed ? (noColor ? '[OK]' : '✅') : providers.codex.installed ? (noColor ? '[!]' : '⚠️') : (noColor ? '[X]' : '❌');
|
|
693
734
|
let modeStatus = pf.uiLabel;
|
|
694
735
|
if (profile.name === 'auto') {
|
|
695
736
|
if (balance.total === 0) {
|
|
@@ -739,6 +780,7 @@ function renderReturningMenu(providers, sessions) {
|
|
|
739
780
|
lines.push(` ${dim('─── Settings')}`);
|
|
740
781
|
lines.push(` ${bold('[p]')} Mode: ${dim(pf.uiLabel)}`);
|
|
741
782
|
lines.push(` ${bold('[b]')} Budget: ${dim('$' + profile.budgets.session_limit_usd + '/session, $' + profile.budgets.daily_limit_usd + '/day')}`);
|
|
783
|
+
lines.push(` ${bold('[x]')} Permissions: ${dim(permissions.claude_skip_permissions || permissions.codex_bypass_sandbox ? 'skip-permissions enabled' : 'safe mode')}`);
|
|
742
784
|
|
|
743
785
|
// ── Auth
|
|
744
786
|
lines.push('');
|
|
@@ -762,7 +804,12 @@ function renderReturningMenu(providers, sessions) {
|
|
|
762
804
|
}
|
|
763
805
|
|
|
764
806
|
lines.push('');
|
|
765
|
-
lines.push(` ${bold('[s]')} Exit to shell
|
|
807
|
+
lines.push(` ${bold('[s]')} Exit to shell`);
|
|
808
|
+
if (sessions.length > 0) {
|
|
809
|
+
lines.push(` ${dim('Enter = continue last · [?] help')}`);
|
|
810
|
+
} else {
|
|
811
|
+
lines.push(` ${dim('Enter = new session · [?] help')}`);
|
|
812
|
+
}
|
|
766
813
|
lines.push('');
|
|
767
814
|
|
|
768
815
|
return lines;
|
|
@@ -835,13 +882,25 @@ function showProfilePicker(rl) {
|
|
|
835
882
|
// ─── Session Runner ───────────────────────────────────────────────────────
|
|
836
883
|
|
|
837
884
|
function runSession(cmd, args, label) {
|
|
885
|
+
const permissions = loadPermissions();
|
|
886
|
+
const finalArgs = [...args];
|
|
887
|
+
if (cmd === 'claude' && permissions.claude_skip_permissions) {
|
|
888
|
+
finalArgs.push('--dangerously-skip-permissions');
|
|
889
|
+
}
|
|
890
|
+
if (cmd === 'codex' && permissions.codex_bypass_sandbox) {
|
|
891
|
+
finalArgs.push('--dangerously-bypass-approvals-and-sandbox');
|
|
892
|
+
}
|
|
893
|
+
|
|
838
894
|
console.log('');
|
|
839
895
|
console.log(` ${label}`);
|
|
840
896
|
console.log(` ${dim('Inside Claude: press Ctrl+C twice to return here.')}`);
|
|
841
897
|
console.log('');
|
|
842
898
|
markLaunched();
|
|
843
|
-
const result = spawnSync(cmd,
|
|
899
|
+
const result = spawnSync(cmd, finalArgs, { stdio: 'inherit' });
|
|
844
900
|
console.log('');
|
|
901
|
+
if (result.status !== 0 && result.status !== null) {
|
|
902
|
+
console.log(` ${yellow('Session exited with code ' + result.status + '.')} ${dim('(' + cmd + ' ' + finalArgs.join(' ') + ')')}`);
|
|
903
|
+
}
|
|
845
904
|
console.log(' Returned to Data Tools — Dual Brain.');
|
|
846
905
|
return result.status || 0;
|
|
847
906
|
}
|
|
@@ -875,12 +934,21 @@ async function mainLoop() {
|
|
|
875
934
|
if (sessions.length > 0) {
|
|
876
935
|
const s = sessions[0];
|
|
877
936
|
if (s.tool === 'codex') {
|
|
878
|
-
runSession('codex', ['
|
|
937
|
+
runSession('codex', ['resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
|
|
879
938
|
} else {
|
|
880
|
-
runSession('claude', ['-r', s.id
|
|
939
|
+
runSession('claude', ['-r', s.id], `Resuming session ${s.id.slice(0, 8)}...`);
|
|
881
940
|
}
|
|
941
|
+
} else if (!providers.claude.authed && !providers.claude.installed) {
|
|
942
|
+
console.log('');
|
|
943
|
+
console.log(` ${yellow('Claude is not installed.')} Install first:`);
|
|
944
|
+
console.log(` ${cyan('curl -fsSL https://claude.ai/install.sh | sh')}`);
|
|
945
|
+
console.log('');
|
|
946
|
+
} else if (!providers.claude.authed) {
|
|
947
|
+
console.log('');
|
|
948
|
+
console.log(` ${yellow('Claude is not authenticated.')} Press ${bold('[j]')} to sign in first.`);
|
|
949
|
+
console.log('');
|
|
882
950
|
} else {
|
|
883
|
-
runSession('claude', [
|
|
951
|
+
runSession('claude', [], 'Starting new session...');
|
|
884
952
|
}
|
|
885
953
|
continue;
|
|
886
954
|
}
|
|
@@ -889,15 +957,21 @@ async function mainLoop() {
|
|
|
889
957
|
if (num >= 1 && num <= 9 && sessions[num - 1]) {
|
|
890
958
|
const s = sessions[num - 1];
|
|
891
959
|
if (s.tool === 'codex') {
|
|
892
|
-
runSession('codex', ['
|
|
960
|
+
runSession('codex', ['resume', s.id], `Resuming codex ${s.id.slice(0, 8)}...`);
|
|
893
961
|
} else {
|
|
894
|
-
runSession('claude', ['-r', s.id
|
|
962
|
+
runSession('claude', ['-r', s.id], `Resuming session ${s.id.slice(0, 8)}...`);
|
|
895
963
|
}
|
|
896
964
|
continue;
|
|
897
965
|
}
|
|
898
966
|
|
|
899
967
|
if (choice === 'n') {
|
|
900
|
-
|
|
968
|
+
if (!providers.claude.authed) {
|
|
969
|
+
console.log('');
|
|
970
|
+
console.log(` ${yellow('Claude needs to be authenticated first.')} Press ${bold('[j]')} to sign in.`);
|
|
971
|
+
console.log('');
|
|
972
|
+
} else {
|
|
973
|
+
runSession('claude', [], 'Starting new session...');
|
|
974
|
+
}
|
|
901
975
|
continue;
|
|
902
976
|
}
|
|
903
977
|
|
|
@@ -916,6 +990,34 @@ async function mainLoop() {
|
|
|
916
990
|
continue;
|
|
917
991
|
}
|
|
918
992
|
|
|
993
|
+
if (choice === 'x' && !firstRun) {
|
|
994
|
+
const permissions = loadPermissions();
|
|
995
|
+
if (permissions.claude_skip_permissions || permissions.codex_bypass_sandbox) {
|
|
996
|
+
savePermissions({
|
|
997
|
+
claude_skip_permissions: false,
|
|
998
|
+
codex_bypass_sandbox: false,
|
|
999
|
+
});
|
|
1000
|
+
console.log('');
|
|
1001
|
+
console.log(` ${green('Permissions set to safe mode.')}`);
|
|
1002
|
+
console.log('');
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
console.log('');
|
|
1007
|
+
const confirm = await new Promise(resolve => rl.question(' WARNING: This enables skip-permissions mode for Claude sessions. Type YES to confirm: ', resolve));
|
|
1008
|
+
if (confirm.trim() === 'YES') {
|
|
1009
|
+
savePermissions({
|
|
1010
|
+
claude_skip_permissions: true,
|
|
1011
|
+
codex_bypass_sandbox: true,
|
|
1012
|
+
});
|
|
1013
|
+
console.log(` ${yellow('Skip-permissions mode enabled for Claude and Codex sessions.')}`);
|
|
1014
|
+
} else {
|
|
1015
|
+
console.log(' No changes made. Safe mode remains enabled.');
|
|
1016
|
+
}
|
|
1017
|
+
console.log('');
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
919
1021
|
if (choice === 'd') {
|
|
920
1022
|
await showToolsMenu(rl);
|
|
921
1023
|
continue;
|
|
@@ -923,9 +1025,14 @@ async function mainLoop() {
|
|
|
923
1025
|
|
|
924
1026
|
if (choice === 'u') {
|
|
925
1027
|
console.log('');
|
|
926
|
-
console.log(' Updating
|
|
1028
|
+
console.log(' Updating Dual Brain...');
|
|
927
1029
|
console.log('');
|
|
928
|
-
spawnSync('npx', ['-y', 'dual-brain', 'update'], { stdio: 'inherit', cwd: CWD });
|
|
1030
|
+
const upd = spawnSync('npx', ['-y', 'dual-brain', 'update'], { stdio: 'inherit', cwd: CWD });
|
|
1031
|
+
if (upd.status !== 0) {
|
|
1032
|
+
console.log('');
|
|
1033
|
+
console.log(` ${yellow('Update failed (exit ' + upd.status + ').')} Try manually: ${cyan('npx -y dual-brain@latest')}`);
|
|
1034
|
+
console.log('');
|
|
1035
|
+
}
|
|
929
1036
|
continue;
|
|
930
1037
|
}
|
|
931
1038
|
|
|
@@ -18,6 +18,8 @@ import { dirname, join, resolve } from 'path';
|
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
19
|
|
|
20
20
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
22
|
+
const SANDBOX = IS_REPLIT ? 'danger-full-access' : 'read-only';
|
|
21
23
|
|
|
22
24
|
const REVIEW_PROMPT_R1 = `You are GPT-5.5 performing Round 1 of a dual-brain code review.
|
|
23
25
|
Claude (Opus) will independently review the same changes, then send you their findings
|
|
@@ -189,7 +191,7 @@ function tryCodexReview(diff, { round = 1, claudeReview = null } = {}) {
|
|
|
189
191
|
const proc = spawnSync(CODEX_BIN, [
|
|
190
192
|
'exec', '--json', '--ephemeral',
|
|
191
193
|
'-c', `model="${model}"`,
|
|
192
|
-
'-s',
|
|
194
|
+
'-s', SANDBOX,
|
|
193
195
|
fullPrompt,
|
|
194
196
|
], {
|
|
195
197
|
input: truncated,
|
|
@@ -25,6 +25,8 @@ import { dirname, join } from 'path';
|
|
|
25
25
|
import { fileURLToPath } from 'url';
|
|
26
26
|
|
|
27
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
29
|
+
const SANDBOX = IS_REPLIT ? 'danger-full-access' : 'read-only';
|
|
28
30
|
|
|
29
31
|
const CODEX_TIMEOUT_MS = 120_000;
|
|
30
32
|
const MODEL = 'gpt-5.5';
|
|
@@ -118,7 +120,7 @@ function runGptAnalysis(codexBin, prompt) {
|
|
|
118
120
|
const proc = spawnSync(codexBin, [
|
|
119
121
|
'exec', '--json', '--ephemeral',
|
|
120
122
|
'-m', MODEL,
|
|
121
|
-
'-s',
|
|
123
|
+
'-s', SANDBOX,
|
|
122
124
|
prompt,
|
|
123
125
|
], {
|
|
124
126
|
encoding: 'utf8',
|
package/hooks/enforce-tier.mjs
CHANGED
|
@@ -14,10 +14,14 @@ const BURST_FILE = resolve(__dirname, '.burst-state');
|
|
|
14
14
|
function detectBurst() {
|
|
15
15
|
const now = Date.now();
|
|
16
16
|
let state = { count: 0, window_start: now };
|
|
17
|
-
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
try {
|
|
18
|
+
try { state = JSON.parse(readFileSync(BURST_FILE, 'utf8')); } catch {}
|
|
19
|
+
if (now - state.window_start > 90_000) state = { count: 0, window_start: now };
|
|
20
|
+
state.count++;
|
|
21
|
+
const tmp = BURST_FILE + '.tmp.' + process.pid;
|
|
22
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
23
|
+
renameSync(tmp, BURST_FILE);
|
|
24
|
+
} catch {}
|
|
21
25
|
return state.count >= 3;
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -27,11 +27,10 @@ const CONFIG_FILE = join(__dirname, '..', 'orchestrator.json');
|
|
|
27
27
|
const EXECUTE_WORDS = /\b(edit|write|fix|implement|modify|refactor|delete|commit|test|build|run|add|update|create)\b/i;
|
|
28
28
|
const SEARCH_WORDS = /\b(explore|search|find|grep|locate|list\s+files|read[-\s]?only|lookup|scan)\b/i;
|
|
29
29
|
const THINK_WORDS = /\b(plan|design|architect|review|audit|security|code[-\s]?review|threat[-\s]?model|complex[-\s]?debug)\b/i;
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
execute: 'danger-full-access',
|
|
33
|
-
think: 'read-only'
|
|
34
|
-
};
|
|
30
|
+
const IS_REPLIT = !!(process.env.REPL_ID || process.env.REPL_SLUG);
|
|
31
|
+
const GPT_TIER_SANDBOX = IS_REPLIT
|
|
32
|
+
? { search: 'danger-full-access', execute: 'danger-full-access', think: 'danger-full-access' }
|
|
33
|
+
: { search: 'read-only', execute: 'danger-full-access', think: 'read-only' };
|
|
35
34
|
const GPT_TIER_PROMPTS = {
|
|
36
35
|
search: 'You are a READ-ONLY search agent. Do NOT edit files.',
|
|
37
36
|
execute: 'You are an execution agent. Edit files directly.',
|
|
@@ -145,10 +144,31 @@ Own this task completely.
|
|
|
145
144
|
// Codex executor
|
|
146
145
|
// ---------------------------------------------------------------------------
|
|
147
146
|
|
|
148
|
-
function
|
|
149
|
-
|
|
147
|
+
function classifyCodexFailure(proc) {
|
|
148
|
+
let failureType = null;
|
|
149
|
+
|
|
150
|
+
if (proc.error?.code === 'ETIMEDOUT') {
|
|
151
|
+
failureType = 'timeout';
|
|
152
|
+
} else if (proc.error?.code === 'ENOENT') {
|
|
153
|
+
failureType = 'not_found';
|
|
154
|
+
} else if (proc.error) {
|
|
155
|
+
failureType = 'spawn_error';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const stderr = (proc.stderr || '').toLowerCase();
|
|
159
|
+
if (stderr.includes('unauthorized') || stderr.includes('401') || stderr.includes('not logged in')) {
|
|
160
|
+
failureType = 'auth';
|
|
161
|
+
} else if (stderr.includes('rate limit') || stderr.includes('429') || stderr.includes('too many')) {
|
|
162
|
+
failureType = 'rate_limit';
|
|
163
|
+
} else if (stderr.includes('timeout') || stderr.includes('timed out')) {
|
|
164
|
+
failureType = 'timeout';
|
|
165
|
+
}
|
|
150
166
|
|
|
151
|
-
|
|
167
|
+
return failureType;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox) {
|
|
171
|
+
return spawnSync(codexBin, [
|
|
152
172
|
'exec', '--json', '--ephemeral',
|
|
153
173
|
'-m', model,
|
|
154
174
|
'-s', sandbox,
|
|
@@ -159,48 +179,82 @@ function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger
|
|
|
159
179
|
timeout: timeoutMs || 120000,
|
|
160
180
|
cwd: cwd || process.cwd(),
|
|
161
181
|
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function executeCodex(codexBin, model, prompt, cwd, timeoutMs, sandbox = 'danger-full-access') {
|
|
185
|
+
const startTime = Date.now();
|
|
186
|
+
|
|
187
|
+
function finalizeAttempt(proc, attemptStartTime, attemptCount) {
|
|
188
|
+
const durationMs = Date.now() - attemptStartTime;
|
|
189
|
+
const failureType = classifyCodexFailure(proc);
|
|
190
|
+
|
|
191
|
+
// Parse JSONL output
|
|
192
|
+
const messages = (proc.stdout || '')
|
|
193
|
+
.split('\n')
|
|
194
|
+
.filter(l => l.trim())
|
|
195
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
196
|
+
.filter(Boolean);
|
|
162
197
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
.filter(
|
|
169
|
-
.map(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
const agentMessages = messages
|
|
199
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'agent_message')
|
|
200
|
+
.map(m => m.item.text);
|
|
201
|
+
|
|
202
|
+
const usage = messages.find(m => m.type === 'turn.completed')?.usage;
|
|
203
|
+
const errors = messages.filter(m => m.type === 'error' || m.type === 'turn.failed');
|
|
204
|
+
const errorMessages = errors.map(e => e.message || e.error?.message || 'unknown');
|
|
205
|
+
|
|
206
|
+
if (proc.error?.message) {
|
|
207
|
+
errorMessages.unshift(proc.error.message);
|
|
208
|
+
}
|
|
209
|
+
if (proc.stderr?.trim() && errorMessages.length === 0 && proc.status !== 0) {
|
|
210
|
+
errorMessages.push(proc.stderr.trim().slice(0, 200));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Detect changed files from command_execution items
|
|
214
|
+
const commands = messages
|
|
215
|
+
.filter(m => m.type === 'item.completed' && m.item?.type === 'command_execution')
|
|
216
|
+
.map(m => m.item);
|
|
217
|
+
|
|
218
|
+
// Estimate startup time: time to first agent message or completed item
|
|
219
|
+
const firstItemTs = messages.find(m => m.type === 'item.completed')?.timestamp;
|
|
220
|
+
let startupMs = null;
|
|
221
|
+
if (firstItemTs) {
|
|
222
|
+
startupMs = Date.parse(firstItemTs) - attemptStartTime;
|
|
223
|
+
if (startupMs < 0 || startupMs > durationMs) startupMs = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
success: proc.status === 0 && errors.length === 0 && !failureType,
|
|
228
|
+
summary: agentMessages.join('\n\n'),
|
|
229
|
+
durationMs,
|
|
230
|
+
startupMs,
|
|
231
|
+
model,
|
|
232
|
+
usage: usage || null,
|
|
233
|
+
errors: errorMessages,
|
|
234
|
+
commands: commands.length,
|
|
235
|
+
exitCode: proc.status,
|
|
236
|
+
signal: proc.signal,
|
|
237
|
+
failureType: failureType || null,
|
|
238
|
+
stderrSummary: proc.stderr?.trim().slice(0, 200) || null,
|
|
239
|
+
spawnErrorMessage: proc.error?.message || null,
|
|
240
|
+
retryCount: attemptCount - 1,
|
|
241
|
+
};
|
|
190
242
|
}
|
|
191
243
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
244
|
+
let attemptCount = 1;
|
|
245
|
+
let attemptStartTime = startTime;
|
|
246
|
+
let proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox);
|
|
247
|
+
let result = finalizeAttempt(proc, attemptStartTime, attemptCount);
|
|
248
|
+
|
|
249
|
+
if (!result.success && (result.failureType === 'rate_limit' || result.failureType === 'timeout')) {
|
|
250
|
+
spawnSync('sleep', ['3'], { stdio: 'ignore' });
|
|
251
|
+
attemptCount += 1;
|
|
252
|
+
attemptStartTime = Date.now();
|
|
253
|
+
proc = runCodexExec(codexBin, model, prompt, cwd, timeoutMs, sandbox);
|
|
254
|
+
result = finalizeAttempt(proc, attemptStartTime, attemptCount);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return result;
|
|
204
258
|
}
|
|
205
259
|
|
|
206
260
|
// ---------------------------------------------------------------------------
|
|
@@ -431,6 +485,19 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
431
485
|
console.log(`║ Model: ${result.model} Duration: ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
432
486
|
console.log('╚══════════════════════════════════════════════════╝');
|
|
433
487
|
} else {
|
|
488
|
+
if (result.failureType) {
|
|
489
|
+
const friendlyMessage = {
|
|
490
|
+
auth: 'Codex not authenticated. Run: codex login --device-auth',
|
|
491
|
+
rate_limit: 'Rate limited by OpenAI. Try again in a few minutes.',
|
|
492
|
+
timeout: 'Codex timed out. Try a simpler task or increase timeout.',
|
|
493
|
+
not_found: 'Codex CLI not found. Run: npm i -g @openai/codex',
|
|
494
|
+
spawn_error: `Failed to start Codex: ${result.spawnErrorMessage || 'unknown spawn error'}`,
|
|
495
|
+
}[result.failureType];
|
|
496
|
+
|
|
497
|
+
if (friendlyMessage) {
|
|
498
|
+
console.error(friendlyMessage);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
434
501
|
console.error('Task failed:', result.errors?.join(', ') || result.error);
|
|
435
502
|
}
|
|
436
503
|
|
package/hooks/health-check.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* node .claude/hooks/health-check.mjs
|
|
7
7
|
*
|
|
8
8
|
* Validates that all hooks are wired, configs are valid, and the system
|
|
9
|
-
* is functioning in a live session. Always exits 0
|
|
9
|
+
* is functioning in a live session. Always exits 0. With --json flag, outputs
|
|
10
|
+
* only JSON to stdout. Without it, prints both table and JSON.
|
|
10
11
|
*
|
|
11
12
|
* Checks:
|
|
12
13
|
* 1. orchestrator.json — exists and parses as valid JSON
|
|
@@ -29,9 +30,11 @@ import { spawnSync } from "child_process";
|
|
|
29
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
31
|
const HOOKS_DIR = __dirname;
|
|
31
32
|
const CONFIG_FILE = join(__dirname, "..", "orchestrator.json");
|
|
33
|
+
const SETTINGS_FILE = join(__dirname, "..", "settings.json");
|
|
32
34
|
const USAGE_FILE_LEGACY = join(__dirname, "usage.jsonl");
|
|
33
35
|
const USAGE_FILE_TODAY = join(__dirname, `usage-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
34
36
|
const WORKSPACE = join(__dirname, "..", "..");
|
|
37
|
+
const jsonOnly = process.argv.includes("--json");
|
|
35
38
|
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
// Status helpers
|
|
@@ -174,6 +177,39 @@ function checkHookScripts() {
|
|
|
174
177
|
);
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
/** 4b. Hook registration — verify required hooks are configured in settings.json */
|
|
181
|
+
function checkHookRegistration() {
|
|
182
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
183
|
+
return check("hook_registration", STATUS.fail, "settings.json not found");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let settings;
|
|
187
|
+
try {
|
|
188
|
+
settings = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return check("hook_registration", STATUS.warn, `invalid JSON: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const preToolUse = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
194
|
+
const postToolUse = Array.isArray(settings?.hooks?.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
195
|
+
|
|
196
|
+
const expectedPre = "node .claude/hooks/enforce-tier.mjs";
|
|
197
|
+
const expectedPost = "node .claude/hooks/cost-logger.mjs";
|
|
198
|
+
|
|
199
|
+
const hasPre = preToolUse.includes(expectedPre);
|
|
200
|
+
const hasPost = postToolUse.includes(expectedPost);
|
|
201
|
+
|
|
202
|
+
if (hasPre && hasPost) {
|
|
203
|
+
return check("hook_registration", STATUS.pass, "required hooks registered");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const missing = [];
|
|
207
|
+
if (!hasPre) missing.push(`PreToolUse: ${expectedPre}`);
|
|
208
|
+
if (!hasPost) missing.push(`PostToolUse: ${expectedPost}`);
|
|
209
|
+
|
|
210
|
+
return check("hook_registration", STATUS.warn, `missing registrations: ${missing.join("; ")}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
177
213
|
/** 5. usage log active — check dated files and legacy for entries from last 15 minutes */
|
|
178
214
|
function checkUsageJsonl() {
|
|
179
215
|
const usageFile = existsSync(USAGE_FILE_TODAY) ? USAGE_FILE_TODAY
|
|
@@ -366,14 +402,21 @@ function main() {
|
|
|
366
402
|
checkPricingVerified(),
|
|
367
403
|
checkModelIntelligence(),
|
|
368
404
|
checkHookScripts(),
|
|
405
|
+
checkHookRegistration(),
|
|
369
406
|
checkUsageJsonl(),
|
|
370
407
|
checkCodexCli(),
|
|
371
408
|
checkGitRepo(),
|
|
372
409
|
];
|
|
373
410
|
|
|
374
411
|
// Print formatted table
|
|
375
|
-
|
|
376
|
-
|
|
412
|
+
const tableOutput = renderTable(checks);
|
|
413
|
+
if (jsonOnly) {
|
|
414
|
+
console.error(tableOutput);
|
|
415
|
+
console.error();
|
|
416
|
+
} else {
|
|
417
|
+
console.log(tableOutput);
|
|
418
|
+
console.log();
|
|
419
|
+
}
|
|
377
420
|
|
|
378
421
|
// Build JSON summary
|
|
379
422
|
const passCount = checks.filter((c) => c.status === "pass").length;
|
package/hooks/quality-gate.mjs
CHANGED
|
@@ -163,6 +163,13 @@ function scoreSensitivity(files, config) {
|
|
|
163
163
|
function matchesSkipPattern(filePath, patterns) {
|
|
164
164
|
const segments = filePath.split('/');
|
|
165
165
|
const basename = segments[segments.length - 1];
|
|
166
|
+
const isTestFile = /\.(test|spec)\.(js|ts|tsx|jsx|mjs)$/.test(basename);
|
|
167
|
+
const isTestDirectory = segments.some(seg => seg === '__tests__' || seg === '__mocks__');
|
|
168
|
+
|
|
169
|
+
if (isTestFile || isTestDirectory) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
return patterns.some(p => {
|
|
167
174
|
if (p.startsWith('.')) return basename.endsWith(p); // extension match
|
|
168
175
|
return segments.some(seg => seg === p || seg.startsWith(p + '.')); // exact segment match
|
package/install.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* npx dual-brain --dry-run # detect only, don't install
|
|
10
10
|
* npx dual-brain --help
|
|
11
11
|
*/
|
|
12
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
12
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
13
13
|
import { createInterface } from 'readline';
|
|
14
14
|
import { dirname, join, resolve } from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
@@ -34,6 +34,7 @@ const flag = (f) => argv.includes(f);
|
|
|
34
34
|
const force = flag('--force');
|
|
35
35
|
const dryRun = flag('--dry-run');
|
|
36
36
|
const jsonOut = flag('--json');
|
|
37
|
+
const restoreNpmFlag = flag('--restore-npm');
|
|
37
38
|
const positional = argv.filter(a => !a.startsWith('-'));
|
|
38
39
|
const subcommand = positional[0] || null;
|
|
39
40
|
|
|
@@ -64,6 +65,7 @@ if (flag('--help') || flag('-h')) {
|
|
|
64
65
|
--force Overwrite all existing config
|
|
65
66
|
--dry-run Detect environment only
|
|
66
67
|
--json Output detection as JSON
|
|
68
|
+
--restore-npm Restore persisted npm token before auth flows
|
|
67
69
|
--help Show this help
|
|
68
70
|
|
|
69
71
|
🎛️ Routing modes:
|
|
@@ -467,9 +469,26 @@ async function authGuidance(env) {
|
|
|
467
469
|
const CODEX_HOME = join(process.env.HOME || '', '.codex');
|
|
468
470
|
const CODEX_PERSIST = resolve(process.cwd(), '.replit-tools', '.codex-persistent');
|
|
469
471
|
|
|
472
|
+
function isGitignored(path) {
|
|
473
|
+
const result = run('git', ['check-ignore', '-q', path], { cwd: process.cwd() });
|
|
474
|
+
return result.status === 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function hasStrictFilePermissions(path) {
|
|
478
|
+
try {
|
|
479
|
+
return (statSync(path).mode & 0o777) === 0o600;
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
470
485
|
function saveCodexCredentials() {
|
|
471
486
|
const authFile = join(CODEX_HOME, 'auth.json');
|
|
472
487
|
if (!existsSync(authFile)) return false;
|
|
488
|
+
if (!isGitignored('.replit-tools')) {
|
|
489
|
+
console.warn('WARNING: .replit-tools is not gitignored. Skipping credential persistence to avoid leaking secrets.');
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
473
492
|
try {
|
|
474
493
|
const auth = readFileSync(authFile, 'utf8');
|
|
475
494
|
if (!auth.trim() || auth.trim() === '{}') return false;
|
|
@@ -477,6 +496,9 @@ function saveCodexCredentials() {
|
|
|
477
496
|
const persisted = join(CODEX_PERSIST, 'auth.json');
|
|
478
497
|
writeFileSync(persisted, auth, { mode: 0o600 });
|
|
479
498
|
try { chmodSync(persisted, 0o600); } catch {}
|
|
499
|
+
if (!hasStrictFilePermissions(persisted)) {
|
|
500
|
+
console.warn(`WARNING: ${relPath(persisted)} permissions are not 0600.`);
|
|
501
|
+
}
|
|
480
502
|
return true;
|
|
481
503
|
} catch { return false; }
|
|
482
504
|
}
|
|
@@ -486,6 +508,10 @@ function restoreCodexCredentials() {
|
|
|
486
508
|
const targetAuth = join(CODEX_HOME, 'auth.json');
|
|
487
509
|
if (existsSync(targetAuth)) return false;
|
|
488
510
|
if (!existsSync(persistedAuth)) return false;
|
|
511
|
+
if (!hasStrictFilePermissions(persistedAuth)) {
|
|
512
|
+
console.warn(`WARNING: ${relPath(persistedAuth)} permissions are not 0600. Skipping restore.`);
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
489
515
|
try {
|
|
490
516
|
const auth = readFileSync(persistedAuth, 'utf8');
|
|
491
517
|
if (!auth.trim() || auth.trim() === '{}') return false;
|
|
@@ -757,6 +783,41 @@ function generateClaudeMd(mode) {
|
|
|
757
783
|
return md;
|
|
758
784
|
}
|
|
759
785
|
|
|
786
|
+
const CLAUDE_MD_MANAGED_START = '<!-- dual-brain:start -->';
|
|
787
|
+
const CLAUDE_MD_MANAGED_END = '<!-- dual-brain:end -->';
|
|
788
|
+
|
|
789
|
+
function renderManagedClaudeSection(content) {
|
|
790
|
+
return `${CLAUDE_MD_MANAGED_START}\n${content.replace(/\s+$/, '')}\n${CLAUDE_MD_MANAGED_END}\n`;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function mergeClaudeMd(existingContent, managedContent) {
|
|
794
|
+
const managedSection = renderManagedClaudeSection(managedContent);
|
|
795
|
+
const startIndex = existingContent.indexOf(CLAUDE_MD_MANAGED_START);
|
|
796
|
+
const endIndex = existingContent.indexOf(CLAUDE_MD_MANAGED_END);
|
|
797
|
+
|
|
798
|
+
if (startIndex !== -1 && endIndex !== -1 && endIndex >= startIndex) {
|
|
799
|
+
const before = existingContent.slice(0, startIndex);
|
|
800
|
+
const after = existingContent.slice(endIndex + CLAUDE_MD_MANAGED_END.length);
|
|
801
|
+
const prefix = before.replace(/\s*$/, '');
|
|
802
|
+
const suffix = after.replace(/^\s*/, '');
|
|
803
|
+
return `${prefix}${prefix ? '\n\n' : ''}${managedSection}${suffix ? `\n${suffix}` : ''}`.replace(/\s+$/, '') + '\n';
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const trimmed = existingContent.replace(/\s+$/, '');
|
|
807
|
+
return `${trimmed}${trimmed ? '\n\n' : ''}${managedSection}`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function writeClaudeMd(targetPath, content) {
|
|
811
|
+
const managedContent = content.replace(/\s+$/, '');
|
|
812
|
+
if (force || !existsSync(targetPath)) {
|
|
813
|
+
writeFileSync(targetPath, renderManagedClaudeSection(managedContent));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const existing = readFileSync(targetPath, 'utf8');
|
|
818
|
+
writeFileSync(targetPath, mergeClaudeMd(existing, managedContent));
|
|
819
|
+
}
|
|
820
|
+
|
|
760
821
|
function generateGitignoreEntries(workspace) {
|
|
761
822
|
const entries = [
|
|
762
823
|
'.claude/hooks/usage-*.jsonl',
|
|
@@ -816,7 +877,7 @@ function install(workspace, env, mode) {
|
|
|
816
877
|
actions.push('✓ settings.json (hooks registered)');
|
|
817
878
|
|
|
818
879
|
const claudeMd = generateClaudeMd(mode);
|
|
819
|
-
|
|
880
|
+
writeClaudeMd(join(target, 'CLAUDE.md'), claudeMd);
|
|
820
881
|
actions.push('✓ CLAUDE.md (session instructions)');
|
|
821
882
|
|
|
822
883
|
const rulesTarget = join(target, 'review-rules.md');
|
|
@@ -1284,6 +1345,10 @@ function cmdExplain() {
|
|
|
1284
1345
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
1285
1346
|
|
|
1286
1347
|
async function main() {
|
|
1348
|
+
if (subcommand === 'auth' || restoreNpmFlag) {
|
|
1349
|
+
restoreNpmToken();
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1287
1352
|
if (subcommand === 'status') {
|
|
1288
1353
|
launchPanel();
|
|
1289
1354
|
return;
|
|
@@ -1293,9 +1358,6 @@ async function main() {
|
|
|
1293
1358
|
if (subcommand === 'budget') { cmdBudget(); return; }
|
|
1294
1359
|
if (subcommand === 'explain') { cmdExplain(); return; }
|
|
1295
1360
|
|
|
1296
|
-
// Restore npm token if missing (for publish access)
|
|
1297
|
-
restoreNpmToken();
|
|
1298
|
-
|
|
1299
1361
|
let env = detectEnvironment();
|
|
1300
1362
|
const startupUpdateInfo = (subcommand === 'update' || dryRun || jsonOut)
|
|
1301
1363
|
? null
|
package/package.json
CHANGED