dual-brain 0.2.30 → 0.3.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/.dual-brain/docs/claude-code-extension-points.md +32 -0
- package/.dual-brain/docs/data-tools-capabilities.md +181 -0
- package/.dual-brain/docs/ecosystem-tools.md +91 -0
- package/.dual-brain/docs/panel-handoff.md +124 -0
- package/.dual-brain/docs/ruflo-analysis.md +48 -0
- package/bin/dual-brain.mjs +56 -56
- package/dist/mcp-server/index.d.ts +27 -0
- package/dist/mcp-server/index.js +359 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/agent-protocol.d.ts +163 -0
- package/dist/src/agent-protocol.js +368 -0
- package/dist/src/agent-protocol.js.map +1 -0
- package/dist/src/agents/registry.d.ts +52 -0
- package/dist/src/agents/registry.js +393 -0
- package/dist/src/agents/registry.js.map +1 -0
- package/dist/src/awareness.d.ts +93 -0
- package/dist/src/awareness.js +406 -0
- package/dist/src/awareness.js.map +1 -0
- package/dist/src/brief.d.ts +48 -0
- package/dist/src/brief.js +179 -0
- package/dist/src/brief.js.map +1 -0
- package/dist/src/calibration.d.ts +32 -0
- package/dist/src/calibration.js +133 -0
- package/dist/src/calibration.js.map +1 -0
- package/dist/src/checkpoint.d.ts +33 -0
- package/dist/src/checkpoint.js +99 -0
- package/dist/src/checkpoint.js.map +1 -0
- package/dist/src/ci-triage.d.ts +33 -0
- package/dist/src/ci-triage.js +193 -0
- package/dist/src/ci-triage.js.map +1 -0
- package/dist/src/cognitive-loop.d.ts +56 -0
- package/dist/src/cognitive-loop.js +495 -0
- package/dist/src/cognitive-loop.js.map +1 -0
- package/dist/src/collaboration.d.ts +147 -0
- package/dist/src/collaboration.js +438 -0
- package/dist/src/collaboration.js.map +1 -0
- package/dist/src/context-intel.d.ts +47 -0
- package/dist/src/context-intel.js +156 -0
- package/dist/src/context-intel.js.map +1 -0
- package/dist/src/context.d.ts +53 -0
- package/dist/src/context.js +332 -0
- package/dist/src/context.js.map +1 -0
- package/dist/src/continuity.d.ts +89 -0
- package/dist/src/continuity.js +230 -0
- package/dist/src/continuity.js.map +1 -0
- package/dist/src/cost-tracker.d.ts +47 -0
- package/dist/src/cost-tracker.js +170 -0
- package/dist/src/cost-tracker.js.map +1 -0
- package/dist/src/debrief.d.ts +53 -0
- package/dist/src/debrief.js +222 -0
- package/dist/src/debrief.js.map +1 -0
- package/dist/src/decide.d.ts +96 -0
- package/dist/src/decide.js +744 -0
- package/dist/src/decide.js.map +1 -0
- package/dist/src/decompose.d.ts +39 -0
- package/dist/src/decompose.js +218 -0
- package/dist/src/decompose.js.map +1 -0
- package/dist/src/detect.d.ts +91 -0
- package/dist/src/detect.js +544 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/dispatch.d.ts +154 -0
- package/dist/src/dispatch.js +1306 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/doctor.d.ts +421 -0
- package/dist/src/doctor.js +1689 -0
- package/dist/src/doctor.js.map +1 -0
- package/dist/src/engine.d.ts +70 -0
- package/dist/src/engine.js +155 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/envelope.d.ts +36 -0
- package/dist/src/envelope.js +80 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/failure-memory.d.ts +55 -0
- package/dist/src/failure-memory.js +175 -0
- package/dist/src/failure-memory.js.map +1 -0
- package/dist/src/fx.d.ts +87 -0
- package/dist/src/fx.js +272 -0
- package/dist/src/fx.js.map +1 -0
- package/dist/src/governance.d.ts +93 -0
- package/dist/src/governance.js +261 -0
- package/dist/src/governance.js.map +1 -0
- package/dist/src/handoff.d.ts +11 -0
- package/dist/src/handoff.js +90 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/head-protocol.d.ts +76 -0
- package/dist/src/head-protocol.js +109 -0
- package/dist/src/head-protocol.js.map +1 -0
- package/dist/src/head.d.ts +222 -0
- package/dist/src/head.js +765 -0
- package/dist/src/head.js.map +1 -0
- package/dist/src/health.d.ts +132 -0
- package/dist/src/health.js +435 -0
- package/dist/src/health.js.map +1 -0
- package/dist/src/inbox.d.ts +70 -0
- package/dist/src/inbox.js +218 -0
- package/dist/src/inbox.js.map +1 -0
- package/dist/src/index.d.ts +33 -0
- package/dist/src/index.js +38 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/install-hooks.d.ts +13 -0
- package/dist/src/install-hooks.js +88 -0
- package/dist/src/install-hooks.js.map +1 -0
- package/dist/src/integrity.d.ts +59 -0
- package/dist/src/integrity.js +206 -0
- package/dist/src/integrity.js.map +1 -0
- package/dist/src/intelligence.d.ts +104 -0
- package/dist/src/intelligence.js +391 -0
- package/dist/src/intelligence.js.map +1 -0
- package/dist/src/ledger.d.ts +54 -0
- package/dist/src/ledger.js +179 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/living-docs.d.ts +14 -0
- package/dist/src/living-docs.js +197 -0
- package/dist/src/living-docs.js.map +1 -0
- package/dist/src/memory-tiers.d.ts +37 -0
- package/dist/src/memory-tiers.js +160 -0
- package/dist/src/memory-tiers.js.map +1 -0
- package/dist/src/model-profiles.d.ts +65 -0
- package/dist/src/model-profiles.js +568 -0
- package/dist/src/model-profiles.js.map +1 -0
- package/dist/src/models.d.ts +58 -0
- package/dist/src/models.js +327 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/narrative.d.ts +54 -0
- package/dist/src/narrative.js +163 -0
- package/dist/src/narrative.js.map +1 -0
- package/dist/src/nextstep.d.ts +16 -0
- package/dist/src/nextstep.js +103 -0
- package/dist/src/nextstep.js.map +1 -0
- package/dist/src/observer.d.ts +18 -0
- package/dist/src/observer.js +251 -0
- package/dist/src/observer.js.map +1 -0
- package/dist/src/outcome.d.ts +110 -0
- package/dist/src/outcome.js +377 -0
- package/dist/src/outcome.js.map +1 -0
- package/dist/src/pipeline.d.ts +167 -0
- package/dist/src/pipeline.js +1503 -0
- package/dist/src/pipeline.js.map +1 -0
- package/dist/src/playbook.d.ts +59 -0
- package/dist/src/playbook.js +238 -0
- package/dist/src/playbook.js.map +1 -0
- package/dist/src/pr-agent.d.ts +97 -0
- package/dist/src/pr-agent.js +195 -0
- package/dist/src/pr-agent.js.map +1 -0
- package/dist/src/predictive.d.ts +57 -0
- package/dist/src/predictive.js +230 -0
- package/dist/src/predictive.js.map +1 -0
- package/dist/src/profile.d.ts +294 -0
- package/dist/src/profile.js +1347 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/prompt-audit.d.ts +22 -0
- package/dist/src/prompt-audit.js +194 -0
- package/dist/src/prompt-audit.js.map +1 -0
- package/dist/src/prompt-intel.d.ts +12 -0
- package/dist/src/prompt-intel.js +321 -0
- package/dist/src/prompt-intel.js.map +1 -0
- package/dist/src/provider-context.d.ts +121 -0
- package/dist/src/provider-context.js +222 -0
- package/dist/src/provider-context.js.map +1 -0
- package/dist/src/provider-manager.d.ts +92 -0
- package/dist/src/provider-manager.js +428 -0
- package/dist/src/provider-manager.js.map +1 -0
- package/dist/src/receipt.d.ts +87 -0
- package/dist/src/receipt.js +326 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/recommendations.d.ts +13 -0
- package/dist/src/recommendations.js +291 -0
- package/dist/src/recommendations.js.map +1 -0
- package/dist/src/redact.d.ts +15 -0
- package/dist/src/redact.js +129 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/replit.d.ts +397 -0
- package/dist/src/replit.js +1160 -0
- package/dist/src/replit.js.map +1 -0
- package/dist/src/repo.d.ts +149 -0
- package/dist/src/repo.js +416 -0
- package/dist/src/repo.js.map +1 -0
- package/dist/src/revert.d.ts +30 -0
- package/dist/src/revert.js +166 -0
- package/dist/src/revert.js.map +1 -0
- package/dist/src/room.d.ts +102 -0
- package/dist/src/room.js +212 -0
- package/dist/src/room.js.map +1 -0
- package/dist/src/routing-advisor.d.ts +57 -0
- package/dist/src/routing-advisor.js +221 -0
- package/dist/src/routing-advisor.js.map +1 -0
- package/dist/src/self-correct.d.ts +40 -0
- package/dist/src/self-correct.js +137 -0
- package/dist/src/self-correct.js.map +1 -0
- package/dist/src/session-lock.d.ts +35 -0
- package/dist/src/session-lock.js +134 -0
- package/dist/src/session-lock.js.map +1 -0
- package/dist/src/session.d.ts +267 -0
- package/dist/src/session.js +1660 -0
- package/dist/src/session.js.map +1 -0
- package/dist/src/settings-tui.d.ts +5 -0
- package/dist/src/settings-tui.js +422 -0
- package/dist/src/settings-tui.js.map +1 -0
- package/dist/src/setup-flow.d.ts +63 -0
- package/dist/src/setup-flow.js +233 -0
- package/dist/src/setup-flow.js.map +1 -0
- package/dist/src/signal.d.ts +19 -0
- package/dist/src/signal.js +122 -0
- package/dist/src/signal.js.map +1 -0
- package/dist/src/simmer.d.ts +85 -0
- package/dist/src/simmer.js +224 -0
- package/dist/src/simmer.js.map +1 -0
- package/dist/src/state-export.d.ts +129 -0
- package/dist/src/state-export.js +233 -0
- package/dist/src/state-export.js.map +1 -0
- package/dist/src/strategy.d.ts +54 -0
- package/dist/src/strategy.js +95 -0
- package/dist/src/strategy.js.map +1 -0
- package/dist/src/subscription.d.ts +40 -0
- package/dist/src/subscription.js +189 -0
- package/dist/src/subscription.js.map +1 -0
- package/dist/src/templates.d.ts +208 -0
- package/dist/src/templates.js +238 -0
- package/dist/src/templates.js.map +1 -0
- package/dist/src/test.d.ts +9 -0
- package/dist/src/test.js +1173 -0
- package/dist/src/test.js.map +1 -0
- package/dist/src/think-engine.d.ts +67 -0
- package/dist/src/think-engine.js +412 -0
- package/dist/src/think-engine.js.map +1 -0
- package/dist/src/tui.d.ts +71 -0
- package/dist/src/tui.js +242 -0
- package/dist/src/tui.js.map +1 -0
- package/dist/src/types.d.ts +177 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/update-check.d.ts +7 -0
- package/dist/src/update-check.js +36 -0
- package/dist/src/update-check.js.map +1 -0
- package/dist/src/wave-planner.d.ts +30 -0
- package/dist/src/wave-planner.js +281 -0
- package/dist/src/wave-planner.js.map +1 -0
- package/hooks/head-guard.sh +41 -0
- package/hooks/task-classifier.mjs +328 -0
- package/hooks/vibe-router.mjs +387 -0
- package/package.json +29 -153
- package/src/agents/registry.mjs +0 -405
- package/src/awareness.mjs +0 -425
- package/src/brief.mjs +0 -266
- package/src/calibration.mjs +0 -148
- package/src/checkpoint.mjs +0 -109
- package/src/ci-triage.mjs +0 -191
- package/src/cognitive-loop.mjs +0 -562
- package/src/collaboration.mjs +0 -545
- package/src/context-intel.mjs +0 -158
- package/src/context.mjs +0 -389
- package/src/continuity.mjs +0 -298
- package/src/cost-tracker.mjs +0 -184
- package/src/debrief.mjs +0 -228
- package/src/decide.mjs +0 -1099
- package/src/decompose.mjs +0 -331
- package/src/detect.mjs +0 -702
- package/src/dispatch.mjs +0 -1447
- package/src/doctor.mjs +0 -1607
- package/src/envelope.mjs +0 -139
- package/src/failure-memory.mjs +0 -178
- package/src/fx.mjs +0 -276
- package/src/governance.mjs +0 -279
- package/src/handoff.mjs +0 -87
- package/src/head-protocol.mjs +0 -128
- package/src/head.mjs +0 -952
- package/src/health.mjs +0 -528
- package/src/inbox.mjs +0 -195
- package/src/index.mjs +0 -44
- package/src/install-hooks.mjs +0 -100
- package/src/integrity.mjs +0 -245
- package/src/intelligence.mjs +0 -447
- package/src/ledger.mjs +0 -196
- package/src/living-docs.mjs +0 -210
- package/src/memory-tiers.mjs +0 -193
- package/src/models.mjs +0 -363
- package/src/narrative.mjs +0 -169
- package/src/nextstep.mjs +0 -100
- package/src/observer.mjs +0 -241
- package/src/outcome.mjs +0 -400
- package/src/pipeline.mjs +0 -1711
- package/src/playbook.mjs +0 -257
- package/src/pr-agent.mjs +0 -214
- package/src/predictive.mjs +0 -250
- package/src/profile.mjs +0 -1411
- package/src/prompt-audit.mjs +0 -231
- package/src/prompt-intel.mjs +0 -325
- package/src/provider-context.mjs +0 -257
- package/src/receipt.mjs +0 -344
- package/src/recommendations.mjs +0 -296
- package/src/redact.mjs +0 -192
- package/src/replit.mjs +0 -1210
- package/src/repo.mjs +0 -445
- package/src/revert.mjs +0 -149
- package/src/routing-advisor.mjs +0 -204
- package/src/self-correct.mjs +0 -147
- package/src/session-lock.mjs +0 -160
- package/src/session.mjs +0 -1655
- package/src/settings-tui.mjs +0 -373
- package/src/setup-flow.mjs +0 -223
- package/src/signal.mjs +0 -115
- package/src/simmer.mjs +0 -241
- package/src/strategy.mjs +0 -235
- package/src/subscription.mjs +0 -212
- package/src/templates.mjs +0 -260
- package/src/think-engine.mjs +0 -428
- package/src/tui.mjs +0 -276
- package/src/update-check.mjs +0 -35
- package/src/wave-planner.mjs +0 -294
package/src/health.mjs
DELETED
|
@@ -1,528 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* health.mjs — Reactive provider health tracking for the Dual-Brain Orchestrator.
|
|
4
|
-
*
|
|
5
|
-
* Replaces budget-pressure estimation with real cooldown state persisted to
|
|
6
|
-
* .dualbrain/health.json. No external dependencies.
|
|
7
|
-
*
|
|
8
|
-
* Exports: getHealth, markHot, markDegraded, markHealthy, checkCooldown,
|
|
9
|
-
* getProviderScore, recordDispatch, getSessionStats, resetHealth
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
-
import { spawnSync } from 'node:child_process';
|
|
15
|
-
|
|
16
|
-
// ─── Auth status (delegates to replit-tools when available) ──────────────────
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Get Claude auth status, preferring replit-tools as the authoritative source.
|
|
20
|
-
*
|
|
21
|
-
* Returns:
|
|
22
|
-
* { ok: boolean, detail: string, source: 'replit-tools' | 'direct' | 'unknown' }
|
|
23
|
-
*
|
|
24
|
-
* @param {string} [cwd]
|
|
25
|
-
*/
|
|
26
|
-
export async function getAuthHealthStatus(cwd) {
|
|
27
|
-
const root = cwd ?? process.cwd();
|
|
28
|
-
|
|
29
|
-
// Try replit-tools first (dynamic import — never breaks if absent)
|
|
30
|
-
try {
|
|
31
|
-
const { getAuthStatus } = await import('./replit.mjs');
|
|
32
|
-
const status = getAuthStatus(root);
|
|
33
|
-
if (status.available) {
|
|
34
|
-
const tokenOk = status.tokenStatus === 'valid' || status.tokenStatus === 'unknown';
|
|
35
|
-
const detail = status.tokenStatus === 'valid'
|
|
36
|
-
? `Auth: OK (via replit-tools${status.expiresAt ? ', expires ' + status.expiresAt : ''})`
|
|
37
|
-
: status.tokenStatus === 'expired'
|
|
38
|
-
? 'Auth: expired (via replit-tools)'
|
|
39
|
-
: status.tokenStatus === 'expiring'
|
|
40
|
-
? 'Auth: expiring soon (via replit-tools)'
|
|
41
|
-
: 'Auth: status unknown (via replit-tools)';
|
|
42
|
-
return { ok: tokenOk, detail, source: 'replit-tools' };
|
|
43
|
-
}
|
|
44
|
-
} catch {
|
|
45
|
-
// replit-tools unavailable — fall through to direct check
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Fall back: check for .credentials.json directly
|
|
49
|
-
const home = process.env.HOME || '/root';
|
|
50
|
-
const credPaths = [
|
|
51
|
-
join(home, '.claude', '.credentials.json'),
|
|
52
|
-
join(root, '.replit-tools', '.claude-persistent', '.credentials.json'),
|
|
53
|
-
join(root, '.claude-persistent', '.credentials.json'),
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
for (const p of credPaths) {
|
|
57
|
-
if (!existsSync(p)) continue;
|
|
58
|
-
try {
|
|
59
|
-
const creds = JSON.parse(readFileSync(p, 'utf8'));
|
|
60
|
-
const oauth = creds?.claudeAiOauth;
|
|
61
|
-
if (oauth?.accessToken) {
|
|
62
|
-
const remainingMs = oauth.expiresAt ? oauth.expiresAt - Date.now() : Infinity;
|
|
63
|
-
const remainingHours = Math.floor(remainingMs / 1000 / 60 / 60);
|
|
64
|
-
if (remainingMs <= 0) {
|
|
65
|
-
return { ok: false, detail: 'Auth: token expired (direct check)', source: 'direct' };
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
ok: true,
|
|
69
|
-
detail: `Auth: OK (direct check, ${remainingHours}h remaining)`,
|
|
70
|
-
source: 'direct',
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
} catch {
|
|
74
|
-
// continue to next path
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// .claude.json oauthAccount check
|
|
79
|
-
const claudeJsonPaths = [
|
|
80
|
-
join(root, '.replit-tools', '.claude-persistent', '.claude.json'),
|
|
81
|
-
join(home, '.claude', '.claude.json'),
|
|
82
|
-
];
|
|
83
|
-
for (const p of claudeJsonPaths) {
|
|
84
|
-
if (!existsSync(p)) continue;
|
|
85
|
-
try {
|
|
86
|
-
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
87
|
-
if (data?.oauthAccount || data?.apiKey) {
|
|
88
|
-
return { ok: true, detail: 'Auth: OK (direct check via .claude.json)', source: 'direct' };
|
|
89
|
-
}
|
|
90
|
-
} catch {
|
|
91
|
-
// continue
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return { ok: false, detail: 'Auth: no credentials found (direct check)', source: 'unknown' };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
|
99
|
-
|
|
100
|
-
const HEALTH_FILE = '.dualbrain/health.json';
|
|
101
|
-
|
|
102
|
-
// Cooldown ladder in minutes: index = attempts - 1, capped at last entry
|
|
103
|
-
const COOLDOWN_LADDER = [5, 15, 45];
|
|
104
|
-
// Window in which repeated hot marks escalate the ladder (ms)
|
|
105
|
-
const ESCALATION_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
106
|
-
|
|
107
|
-
// ─── File I/O ────────────────────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
function healthPath(cwd) {
|
|
110
|
-
return join(cwd ?? process.cwd(), HEALTH_FILE);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function loadRaw(cwd) {
|
|
114
|
-
const p = healthPath(cwd);
|
|
115
|
-
if (!existsSync(p)) return { states: {}, session: null };
|
|
116
|
-
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { states: {}, session: null }; }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function saveRaw(data, cwd) {
|
|
120
|
-
const p = healthPath(cwd);
|
|
121
|
-
mkdirSync(join(cwd ?? process.cwd(), '.dualbrain'), { recursive: true });
|
|
122
|
-
writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function key(provider, modelClass) {
|
|
126
|
-
return `${provider}:${modelClass}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ─── Session helpers ──────────────────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
function ensureSession(data) {
|
|
132
|
-
if (!data.session || typeof data.session !== 'object') {
|
|
133
|
-
data.session = { startedAt: new Date().toISOString(), dispatches: [] };
|
|
134
|
-
}
|
|
135
|
-
if (!Array.isArray(data.session.dispatches)) data.session.dispatches = [];
|
|
136
|
-
return data;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ─── Exported: getHealth ─────────────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Return the raw health data (states + session).
|
|
143
|
-
* @param {string} [cwd]
|
|
144
|
-
* @returns {{ states: object, session: object }}
|
|
145
|
-
*/
|
|
146
|
-
export function getHealth(cwd) {
|
|
147
|
-
return loadRaw(cwd);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ─── Exported: markHot ───────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Mark a provider+model as hot (rate-limited). Escalates cooldown on repeat.
|
|
154
|
-
* @param {string} provider
|
|
155
|
-
* @param {string} modelClass
|
|
156
|
-
* @param {string} [cwd]
|
|
157
|
-
*/
|
|
158
|
-
export function markHot(provider, modelClass, cwd) {
|
|
159
|
-
const data = loadRaw(cwd);
|
|
160
|
-
const k = key(provider, modelClass);
|
|
161
|
-
const existing = data.states[k] ?? {};
|
|
162
|
-
const now = Date.now();
|
|
163
|
-
|
|
164
|
-
// Count how many times this was already marked hot within the escalation window
|
|
165
|
-
let attempts = (existing.attempts ?? 0);
|
|
166
|
-
const sinceMs = existing.since ? now - Date.parse(existing.since) : Infinity;
|
|
167
|
-
if (sinceMs < ESCALATION_WINDOW_MS && existing.status === 'hot') {
|
|
168
|
-
attempts += 1;
|
|
169
|
-
} else if (existing.status !== 'hot') {
|
|
170
|
-
// First time hot (or was healthy/probing before): reset counter to 1
|
|
171
|
-
attempts = 1;
|
|
172
|
-
}
|
|
173
|
-
// Clamp to ladder length
|
|
174
|
-
const ladderIdx = Math.min(attempts - 1, COOLDOWN_LADDER.length - 1);
|
|
175
|
-
const cooldownMinutes = COOLDOWN_LADDER[ladderIdx];
|
|
176
|
-
|
|
177
|
-
data.states[k] = {
|
|
178
|
-
status: 'hot',
|
|
179
|
-
since: new Date().toISOString(),
|
|
180
|
-
cooldownMinutes,
|
|
181
|
-
attempts,
|
|
182
|
-
};
|
|
183
|
-
saveRaw(data, cwd);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ─── Exported: markDegraded ──────────────────────────────────────────────────
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Signal soft degradation (slow responses, elevated errors) without full cooldown.
|
|
190
|
-
* @param {string} provider
|
|
191
|
-
* @param {string} modelClass
|
|
192
|
-
* @param {string} [cwd]
|
|
193
|
-
*/
|
|
194
|
-
export function markDegraded(provider, modelClass, cwd) {
|
|
195
|
-
const data = loadRaw(cwd);
|
|
196
|
-
const k = key(provider, modelClass);
|
|
197
|
-
// Only downgrade if currently healthy or probing — never upgrade from hot
|
|
198
|
-
if (!data.states[k] || ['healthy', 'probing'].includes(data.states[k].status)) {
|
|
199
|
-
data.states[k] = { status: 'degraded', since: new Date().toISOString() };
|
|
200
|
-
saveRaw(data, cwd);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ─── Exported: markHealthy ───────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Clear hot/degraded state and reset attempt counter.
|
|
208
|
-
* @param {string} provider
|
|
209
|
-
* @param {string} modelClass
|
|
210
|
-
* @param {string} [cwd]
|
|
211
|
-
*/
|
|
212
|
-
export function markHealthy(provider, modelClass, cwd) {
|
|
213
|
-
const data = loadRaw(cwd);
|
|
214
|
-
const k = key(provider, modelClass);
|
|
215
|
-
data.states[k] = { status: 'healthy', since: new Date().toISOString() };
|
|
216
|
-
saveRaw(data, cwd);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ─── Exported: checkCooldown ─────────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Returns true if the cooldown for a hot provider+model has expired.
|
|
223
|
-
* Side-effect: transitions status from 'hot' to 'probing' when expired.
|
|
224
|
-
* @param {string} provider
|
|
225
|
-
* @param {string} modelClass
|
|
226
|
-
* @param {string} [cwd]
|
|
227
|
-
* @returns {boolean} true = cooldown expired, ready to probe
|
|
228
|
-
*/
|
|
229
|
-
export function checkCooldown(provider, modelClass, cwd) {
|
|
230
|
-
const data = loadRaw(cwd);
|
|
231
|
-
const k = key(provider, modelClass);
|
|
232
|
-
const state = data.states[k];
|
|
233
|
-
if (!state || state.status !== 'hot') return true; // not hot → no cooldown
|
|
234
|
-
|
|
235
|
-
const sinceMs = Date.parse(state.since);
|
|
236
|
-
const cooldownMs = (state.cooldownMinutes ?? 5) * 60 * 1000;
|
|
237
|
-
const expired = Date.now() - sinceMs >= cooldownMs;
|
|
238
|
-
|
|
239
|
-
if (expired) {
|
|
240
|
-
// Transition to probing
|
|
241
|
-
data.states[k] = { ...state, status: 'probing', probingAt: new Date().toISOString() };
|
|
242
|
-
saveRaw(data, cwd);
|
|
243
|
-
return true;
|
|
244
|
-
}
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ─── Exported: getProviderScore ──────────────────────────────────────────────
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Returns a 0-100 routing preference score for a provider+model.
|
|
252
|
-
* healthy=100, degraded=50, probing=25, hot=0
|
|
253
|
-
* @param {string} provider
|
|
254
|
-
* @param {string} modelClass
|
|
255
|
-
* @param {string} [cwd]
|
|
256
|
-
* @returns {number}
|
|
257
|
-
*/
|
|
258
|
-
export function getProviderScore(provider, modelClass, cwd) {
|
|
259
|
-
const data = loadRaw(cwd);
|
|
260
|
-
const k = key(provider, modelClass);
|
|
261
|
-
const state = data.states[k];
|
|
262
|
-
if (!state) return 100;
|
|
263
|
-
switch (state.status) {
|
|
264
|
-
case 'healthy': return 100;
|
|
265
|
-
case 'degraded': return 50;
|
|
266
|
-
case 'probing': return 25;
|
|
267
|
-
case 'hot': return 0;
|
|
268
|
-
default: return 100;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ─── Exported: recordDispatch ────────────────────────────────────────────────
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Log a successful dispatch for session tracking.
|
|
276
|
-
* @param {string} provider
|
|
277
|
-
* @param {string} modelClass
|
|
278
|
-
* @param {number} tokens
|
|
279
|
-
* @param {string} [cwd]
|
|
280
|
-
*/
|
|
281
|
-
export function recordDispatch(provider, modelClass, tokens, cwd) {
|
|
282
|
-
const data = ensureSession(loadRaw(cwd));
|
|
283
|
-
data.session.dispatches.push({
|
|
284
|
-
provider,
|
|
285
|
-
model: modelClass,
|
|
286
|
-
tokens: tokens ?? 0,
|
|
287
|
-
at: new Date().toISOString(),
|
|
288
|
-
});
|
|
289
|
-
saveRaw(data, cwd);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// ─── Exported: getSessionStats ───────────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Return per-provider aggregated call + token counts for the current session.
|
|
296
|
-
* @param {string} [cwd]
|
|
297
|
-
* @returns {{ [provider: string]: { calls: number, tokens: number } }}
|
|
298
|
-
*/
|
|
299
|
-
export function getSessionStats(cwd) {
|
|
300
|
-
const { session } = loadRaw(cwd);
|
|
301
|
-
const stats = {};
|
|
302
|
-
for (const d of (session?.dispatches ?? [])) {
|
|
303
|
-
if (!stats[d.provider]) stats[d.provider] = { calls: 0, tokens: 0 };
|
|
304
|
-
stats[d.provider].calls += 1;
|
|
305
|
-
stats[d.provider].tokens += (d.tokens ?? 0);
|
|
306
|
-
}
|
|
307
|
-
return stats;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ─── Exported: resetHealth ───────────────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Wipe all health state (states + session).
|
|
314
|
-
* @param {string} [cwd]
|
|
315
|
-
*/
|
|
316
|
-
export function resetHealth(cwd) {
|
|
317
|
-
saveRaw({ states: {}, session: null }, cwd);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ─── Network timeout guard ────────────────────────────────────────────────────
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Ping a provider URL with a bounded timeout so slow networks don't hang the CLI.
|
|
324
|
-
*
|
|
325
|
-
* Uses AbortController to enforce the deadline. On timeout or network error the
|
|
326
|
-
* caller receives { ok: false, status: 'timeout' } rather than hanging forever.
|
|
327
|
-
*
|
|
328
|
-
* @param {string} url
|
|
329
|
-
* @param {{ timeoutMs?: number, headers?: Record<string,string> }} [opts]
|
|
330
|
-
* @returns {Promise<{ ok: boolean, status: 'ok'|'timeout'|'error', detail?: string }>}
|
|
331
|
-
*/
|
|
332
|
-
export async function pingProvider(url, opts = {}) {
|
|
333
|
-
const timeoutMs = opts.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
|
|
334
|
-
const controller = new AbortController();
|
|
335
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
336
|
-
try {
|
|
337
|
-
const res = await fetch(url, {
|
|
338
|
-
method: 'HEAD',
|
|
339
|
-
signal: controller.signal,
|
|
340
|
-
headers: opts.headers ?? {},
|
|
341
|
-
});
|
|
342
|
-
clearTimeout(timer);
|
|
343
|
-
return { ok: res.ok, status: 'ok', detail: String(res.status) };
|
|
344
|
-
} catch (err) {
|
|
345
|
-
clearTimeout(timer);
|
|
346
|
-
const isTimeout = err?.name === 'AbortError';
|
|
347
|
-
return {
|
|
348
|
-
ok: false,
|
|
349
|
-
status: isTimeout ? 'timeout' : 'error',
|
|
350
|
-
detail: isTimeout ? `Provider health: unknown (timeout after ${timeoutMs}ms)` : String(err?.message),
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// ─── Remaining cooldown helper (used by status display) ──────────────────────
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Returns remaining cooldown in minutes for a hot provider+model, or 0.
|
|
359
|
-
* @param {string} provider
|
|
360
|
-
* @param {string} modelClass
|
|
361
|
-
* @param {string} [cwd]
|
|
362
|
-
* @returns {number}
|
|
363
|
-
*/
|
|
364
|
-
export function remainingCooldownMinutes(provider, modelClass, cwd) {
|
|
365
|
-
const data = loadRaw(cwd);
|
|
366
|
-
const k = key(provider, modelClass);
|
|
367
|
-
const state = data.states[k];
|
|
368
|
-
if (!state || state.status !== 'hot') return 0;
|
|
369
|
-
const elapsedMs = Date.now() - Date.parse(state.since);
|
|
370
|
-
const cooldownMs = (state.cooldownMinutes ?? 5) * 60 * 1000;
|
|
371
|
-
const remaining = cooldownMs - elapsedMs;
|
|
372
|
-
return remaining > 0 ? Math.ceil(remaining / 60_000) : 0;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ─── Hook health check ────────────────────────────────────────────────────────
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Extract the file path from a hook command string.
|
|
379
|
-
* Handles patterns like `node /path/to/hook.mjs` or `node /path/to/hook.mjs --flag`.
|
|
380
|
-
* Returns null if the pattern doesn't match.
|
|
381
|
-
* @param {string} command
|
|
382
|
-
* @returns {string|null}
|
|
383
|
-
*/
|
|
384
|
-
function extractHookPath(command) {
|
|
385
|
-
if (typeof command !== 'string') return null;
|
|
386
|
-
const match = command.match(/node\s+([^\s]+\.mjs)/);
|
|
387
|
-
return match ? match[1] : null;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Collect all hook entries from a settings object, returning
|
|
392
|
-
* [{ command, eventType }] pairs.
|
|
393
|
-
* @param {object} settings
|
|
394
|
-
* @returns {{ command: string, eventType: string }[]}
|
|
395
|
-
*/
|
|
396
|
-
function collectHookCommands(settings) {
|
|
397
|
-
const entries = [];
|
|
398
|
-
const hooks = settings?.hooks ?? {};
|
|
399
|
-
for (const [eventType, matchers] of Object.entries(hooks)) {
|
|
400
|
-
if (!Array.isArray(matchers)) continue;
|
|
401
|
-
for (const matcher of matchers) {
|
|
402
|
-
for (const hook of (matcher?.hooks ?? [])) {
|
|
403
|
-
if (hook?.type === 'command' && typeof hook.command === 'string') {
|
|
404
|
-
entries.push({ command: hook.command, eventType });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
return entries;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Load and parse a JSON settings file. Returns {} on any error.
|
|
414
|
-
* @param {string} filePath
|
|
415
|
-
* @returns {object}
|
|
416
|
-
*/
|
|
417
|
-
function loadSettings(filePath) {
|
|
418
|
-
if (!existsSync(filePath)) return {};
|
|
419
|
-
try {
|
|
420
|
-
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
421
|
-
} catch {
|
|
422
|
-
return {};
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Check the health of all hook files referenced in project-local and global
|
|
428
|
-
* Claude Code settings.
|
|
429
|
-
*
|
|
430
|
-
* @param {string} [cwd] — project root (defaults to process.cwd())
|
|
431
|
-
* @returns {{
|
|
432
|
-
* healthy: boolean,
|
|
433
|
-
* hooks: Array<{ path: string, exists: boolean, syntaxValid: boolean, source: 'local'|'global', duplicate: boolean }>,
|
|
434
|
-
* conflicts: string[],
|
|
435
|
-
* degraded: string[],
|
|
436
|
-
* missing: string[],
|
|
437
|
-
* }}
|
|
438
|
-
*/
|
|
439
|
-
export function checkHookHealth(cwd) {
|
|
440
|
-
const root = cwd ?? process.cwd();
|
|
441
|
-
const home = process.env.HOME || '/root';
|
|
442
|
-
|
|
443
|
-
const localSettingsPath = join(root, '.claude', 'settings.local.json');
|
|
444
|
-
const globalSettingsPath = join(home, '.claude', 'settings.json');
|
|
445
|
-
|
|
446
|
-
const localSettings = loadSettings(localSettingsPath);
|
|
447
|
-
const globalSettings = loadSettings(globalSettingsPath);
|
|
448
|
-
|
|
449
|
-
const localCommands = collectHookCommands(localSettings);
|
|
450
|
-
const globalCommands = collectHookCommands(globalSettings);
|
|
451
|
-
|
|
452
|
-
// Build a set of hook paths from local settings for duplicate detection
|
|
453
|
-
const localPaths = new Set(localCommands.map(e => extractHookPath(e.command)).filter(Boolean));
|
|
454
|
-
const globalPaths = new Set(globalCommands.map(e => extractHookPath(e.command)).filter(Boolean));
|
|
455
|
-
|
|
456
|
-
// Paths that appear in both local and global are conflicts
|
|
457
|
-
const conflictPaths = new Set([...localPaths].filter(p => globalPaths.has(p)));
|
|
458
|
-
|
|
459
|
-
const hookResults = [];
|
|
460
|
-
const conflicts = [];
|
|
461
|
-
const degraded = [];
|
|
462
|
-
const missing = [];
|
|
463
|
-
|
|
464
|
-
function processEntry(entry, source) {
|
|
465
|
-
const path = extractHookPath(entry.command);
|
|
466
|
-
if (!path) return; // non-node hook — skip
|
|
467
|
-
|
|
468
|
-
const fileExists = existsSync(path);
|
|
469
|
-
const isDuplicate = conflictPaths.has(path);
|
|
470
|
-
|
|
471
|
-
let syntaxValid = false;
|
|
472
|
-
if (fileExists) {
|
|
473
|
-
try {
|
|
474
|
-
const check = spawnSync('node', ['--check', path], {
|
|
475
|
-
timeout: 3000,
|
|
476
|
-
encoding: 'utf8',
|
|
477
|
-
});
|
|
478
|
-
syntaxValid = check.status === 0;
|
|
479
|
-
} catch {
|
|
480
|
-
syntaxValid = false;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const record = { path, exists: fileExists, syntaxValid, source, duplicate: isDuplicate };
|
|
485
|
-
hookResults.push(record);
|
|
486
|
-
|
|
487
|
-
if (!fileExists) {
|
|
488
|
-
missing.push(`${source}: ${path} (file not found)`);
|
|
489
|
-
} else if (!syntaxValid) {
|
|
490
|
-
degraded.push(`${source}: ${path} (syntax error)`);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (isDuplicate && source === 'global') {
|
|
494
|
-
// Only report the conflict once (when we encounter it from the global side)
|
|
495
|
-
conflicts.push(`Hook defined in both local and global settings: ${path}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
for (const entry of localCommands) processEntry(entry, 'local');
|
|
500
|
-
for (const entry of globalCommands) processEntry(entry, 'global');
|
|
501
|
-
|
|
502
|
-
const healthy = missing.length === 0 && degraded.length === 0 && conflicts.length === 0;
|
|
503
|
-
|
|
504
|
-
return { healthy, hooks: hookResults, conflicts, degraded, missing };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// ─── Hook smoke test ──────────────────────────────────────────────────────────
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Run a hook with deliberately malformed input to verify it fails open
|
|
511
|
-
* (exits 0 even on bad input, so it never blocks the Claude Code flow).
|
|
512
|
-
*
|
|
513
|
-
* @param {string} hookPath
|
|
514
|
-
* @returns {{ path: string, failsOpen: boolean, stderr?: string, error?: string }}
|
|
515
|
-
*/
|
|
516
|
-
export function runHookSmoke(hookPath) {
|
|
517
|
-
try {
|
|
518
|
-
const result = spawnSync('node', [hookPath], {
|
|
519
|
-
input: 'not valid json',
|
|
520
|
-
timeout: 5000,
|
|
521
|
-
encoding: 'utf8',
|
|
522
|
-
});
|
|
523
|
-
// Exit 0 = fails open (good), Exit non-0 = fails closed (bad)
|
|
524
|
-
return { path: hookPath, failsOpen: result.status === 0, stderr: (result.stderr || '').slice(0, 200) };
|
|
525
|
-
} catch {
|
|
526
|
-
return { path: hookPath, failsOpen: false, error: 'smoke test crashed' };
|
|
527
|
-
}
|
|
528
|
-
}
|