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.
Files changed (309) hide show
  1. package/.dual-brain/docs/claude-code-extension-points.md +32 -0
  2. package/.dual-brain/docs/data-tools-capabilities.md +181 -0
  3. package/.dual-brain/docs/ecosystem-tools.md +91 -0
  4. package/.dual-brain/docs/panel-handoff.md +124 -0
  5. package/.dual-brain/docs/ruflo-analysis.md +48 -0
  6. package/bin/dual-brain.mjs +56 -56
  7. package/dist/mcp-server/index.d.ts +27 -0
  8. package/dist/mcp-server/index.js +359 -0
  9. package/dist/mcp-server/index.js.map +1 -0
  10. package/dist/src/agent-protocol.d.ts +163 -0
  11. package/dist/src/agent-protocol.js +368 -0
  12. package/dist/src/agent-protocol.js.map +1 -0
  13. package/dist/src/agents/registry.d.ts +52 -0
  14. package/dist/src/agents/registry.js +393 -0
  15. package/dist/src/agents/registry.js.map +1 -0
  16. package/dist/src/awareness.d.ts +93 -0
  17. package/dist/src/awareness.js +406 -0
  18. package/dist/src/awareness.js.map +1 -0
  19. package/dist/src/brief.d.ts +48 -0
  20. package/dist/src/brief.js +179 -0
  21. package/dist/src/brief.js.map +1 -0
  22. package/dist/src/calibration.d.ts +32 -0
  23. package/dist/src/calibration.js +133 -0
  24. package/dist/src/calibration.js.map +1 -0
  25. package/dist/src/checkpoint.d.ts +33 -0
  26. package/dist/src/checkpoint.js +99 -0
  27. package/dist/src/checkpoint.js.map +1 -0
  28. package/dist/src/ci-triage.d.ts +33 -0
  29. package/dist/src/ci-triage.js +193 -0
  30. package/dist/src/ci-triage.js.map +1 -0
  31. package/dist/src/cognitive-loop.d.ts +56 -0
  32. package/dist/src/cognitive-loop.js +495 -0
  33. package/dist/src/cognitive-loop.js.map +1 -0
  34. package/dist/src/collaboration.d.ts +147 -0
  35. package/dist/src/collaboration.js +438 -0
  36. package/dist/src/collaboration.js.map +1 -0
  37. package/dist/src/context-intel.d.ts +47 -0
  38. package/dist/src/context-intel.js +156 -0
  39. package/dist/src/context-intel.js.map +1 -0
  40. package/dist/src/context.d.ts +53 -0
  41. package/dist/src/context.js +332 -0
  42. package/dist/src/context.js.map +1 -0
  43. package/dist/src/continuity.d.ts +89 -0
  44. package/dist/src/continuity.js +230 -0
  45. package/dist/src/continuity.js.map +1 -0
  46. package/dist/src/cost-tracker.d.ts +47 -0
  47. package/dist/src/cost-tracker.js +170 -0
  48. package/dist/src/cost-tracker.js.map +1 -0
  49. package/dist/src/debrief.d.ts +53 -0
  50. package/dist/src/debrief.js +222 -0
  51. package/dist/src/debrief.js.map +1 -0
  52. package/dist/src/decide.d.ts +96 -0
  53. package/dist/src/decide.js +744 -0
  54. package/dist/src/decide.js.map +1 -0
  55. package/dist/src/decompose.d.ts +39 -0
  56. package/dist/src/decompose.js +218 -0
  57. package/dist/src/decompose.js.map +1 -0
  58. package/dist/src/detect.d.ts +91 -0
  59. package/dist/src/detect.js +544 -0
  60. package/dist/src/detect.js.map +1 -0
  61. package/dist/src/dispatch.d.ts +154 -0
  62. package/dist/src/dispatch.js +1306 -0
  63. package/dist/src/dispatch.js.map +1 -0
  64. package/dist/src/doctor.d.ts +421 -0
  65. package/dist/src/doctor.js +1689 -0
  66. package/dist/src/doctor.js.map +1 -0
  67. package/dist/src/engine.d.ts +70 -0
  68. package/dist/src/engine.js +155 -0
  69. package/dist/src/engine.js.map +1 -0
  70. package/dist/src/envelope.d.ts +36 -0
  71. package/dist/src/envelope.js +80 -0
  72. package/dist/src/envelope.js.map +1 -0
  73. package/dist/src/failure-memory.d.ts +55 -0
  74. package/dist/src/failure-memory.js +175 -0
  75. package/dist/src/failure-memory.js.map +1 -0
  76. package/dist/src/fx.d.ts +87 -0
  77. package/dist/src/fx.js +272 -0
  78. package/dist/src/fx.js.map +1 -0
  79. package/dist/src/governance.d.ts +93 -0
  80. package/dist/src/governance.js +261 -0
  81. package/dist/src/governance.js.map +1 -0
  82. package/dist/src/handoff.d.ts +11 -0
  83. package/dist/src/handoff.js +90 -0
  84. package/dist/src/handoff.js.map +1 -0
  85. package/dist/src/head-protocol.d.ts +76 -0
  86. package/dist/src/head-protocol.js +109 -0
  87. package/dist/src/head-protocol.js.map +1 -0
  88. package/dist/src/head.d.ts +222 -0
  89. package/dist/src/head.js +765 -0
  90. package/dist/src/head.js.map +1 -0
  91. package/dist/src/health.d.ts +132 -0
  92. package/dist/src/health.js +435 -0
  93. package/dist/src/health.js.map +1 -0
  94. package/dist/src/inbox.d.ts +70 -0
  95. package/dist/src/inbox.js +218 -0
  96. package/dist/src/inbox.js.map +1 -0
  97. package/dist/src/index.d.ts +33 -0
  98. package/dist/src/index.js +38 -0
  99. package/dist/src/index.js.map +1 -0
  100. package/dist/src/install-hooks.d.ts +13 -0
  101. package/dist/src/install-hooks.js +88 -0
  102. package/dist/src/install-hooks.js.map +1 -0
  103. package/dist/src/integrity.d.ts +59 -0
  104. package/dist/src/integrity.js +206 -0
  105. package/dist/src/integrity.js.map +1 -0
  106. package/dist/src/intelligence.d.ts +104 -0
  107. package/dist/src/intelligence.js +391 -0
  108. package/dist/src/intelligence.js.map +1 -0
  109. package/dist/src/ledger.d.ts +54 -0
  110. package/dist/src/ledger.js +179 -0
  111. package/dist/src/ledger.js.map +1 -0
  112. package/dist/src/living-docs.d.ts +14 -0
  113. package/dist/src/living-docs.js +197 -0
  114. package/dist/src/living-docs.js.map +1 -0
  115. package/dist/src/memory-tiers.d.ts +37 -0
  116. package/dist/src/memory-tiers.js +160 -0
  117. package/dist/src/memory-tiers.js.map +1 -0
  118. package/dist/src/model-profiles.d.ts +65 -0
  119. package/dist/src/model-profiles.js +568 -0
  120. package/dist/src/model-profiles.js.map +1 -0
  121. package/dist/src/models.d.ts +58 -0
  122. package/dist/src/models.js +327 -0
  123. package/dist/src/models.js.map +1 -0
  124. package/dist/src/narrative.d.ts +54 -0
  125. package/dist/src/narrative.js +163 -0
  126. package/dist/src/narrative.js.map +1 -0
  127. package/dist/src/nextstep.d.ts +16 -0
  128. package/dist/src/nextstep.js +103 -0
  129. package/dist/src/nextstep.js.map +1 -0
  130. package/dist/src/observer.d.ts +18 -0
  131. package/dist/src/observer.js +251 -0
  132. package/dist/src/observer.js.map +1 -0
  133. package/dist/src/outcome.d.ts +110 -0
  134. package/dist/src/outcome.js +377 -0
  135. package/dist/src/outcome.js.map +1 -0
  136. package/dist/src/pipeline.d.ts +167 -0
  137. package/dist/src/pipeline.js +1503 -0
  138. package/dist/src/pipeline.js.map +1 -0
  139. package/dist/src/playbook.d.ts +59 -0
  140. package/dist/src/playbook.js +238 -0
  141. package/dist/src/playbook.js.map +1 -0
  142. package/dist/src/pr-agent.d.ts +97 -0
  143. package/dist/src/pr-agent.js +195 -0
  144. package/dist/src/pr-agent.js.map +1 -0
  145. package/dist/src/predictive.d.ts +57 -0
  146. package/dist/src/predictive.js +230 -0
  147. package/dist/src/predictive.js.map +1 -0
  148. package/dist/src/profile.d.ts +294 -0
  149. package/dist/src/profile.js +1347 -0
  150. package/dist/src/profile.js.map +1 -0
  151. package/dist/src/prompt-audit.d.ts +22 -0
  152. package/dist/src/prompt-audit.js +194 -0
  153. package/dist/src/prompt-audit.js.map +1 -0
  154. package/dist/src/prompt-intel.d.ts +12 -0
  155. package/dist/src/prompt-intel.js +321 -0
  156. package/dist/src/prompt-intel.js.map +1 -0
  157. package/dist/src/provider-context.d.ts +121 -0
  158. package/dist/src/provider-context.js +222 -0
  159. package/dist/src/provider-context.js.map +1 -0
  160. package/dist/src/provider-manager.d.ts +92 -0
  161. package/dist/src/provider-manager.js +428 -0
  162. package/dist/src/provider-manager.js.map +1 -0
  163. package/dist/src/receipt.d.ts +87 -0
  164. package/dist/src/receipt.js +326 -0
  165. package/dist/src/receipt.js.map +1 -0
  166. package/dist/src/recommendations.d.ts +13 -0
  167. package/dist/src/recommendations.js +291 -0
  168. package/dist/src/recommendations.js.map +1 -0
  169. package/dist/src/redact.d.ts +15 -0
  170. package/dist/src/redact.js +129 -0
  171. package/dist/src/redact.js.map +1 -0
  172. package/dist/src/replit.d.ts +397 -0
  173. package/dist/src/replit.js +1160 -0
  174. package/dist/src/replit.js.map +1 -0
  175. package/dist/src/repo.d.ts +149 -0
  176. package/dist/src/repo.js +416 -0
  177. package/dist/src/repo.js.map +1 -0
  178. package/dist/src/revert.d.ts +30 -0
  179. package/dist/src/revert.js +166 -0
  180. package/dist/src/revert.js.map +1 -0
  181. package/dist/src/room.d.ts +102 -0
  182. package/dist/src/room.js +212 -0
  183. package/dist/src/room.js.map +1 -0
  184. package/dist/src/routing-advisor.d.ts +57 -0
  185. package/dist/src/routing-advisor.js +221 -0
  186. package/dist/src/routing-advisor.js.map +1 -0
  187. package/dist/src/self-correct.d.ts +40 -0
  188. package/dist/src/self-correct.js +137 -0
  189. package/dist/src/self-correct.js.map +1 -0
  190. package/dist/src/session-lock.d.ts +35 -0
  191. package/dist/src/session-lock.js +134 -0
  192. package/dist/src/session-lock.js.map +1 -0
  193. package/dist/src/session.d.ts +267 -0
  194. package/dist/src/session.js +1660 -0
  195. package/dist/src/session.js.map +1 -0
  196. package/dist/src/settings-tui.d.ts +5 -0
  197. package/dist/src/settings-tui.js +422 -0
  198. package/dist/src/settings-tui.js.map +1 -0
  199. package/dist/src/setup-flow.d.ts +63 -0
  200. package/dist/src/setup-flow.js +233 -0
  201. package/dist/src/setup-flow.js.map +1 -0
  202. package/dist/src/signal.d.ts +19 -0
  203. package/dist/src/signal.js +122 -0
  204. package/dist/src/signal.js.map +1 -0
  205. package/dist/src/simmer.d.ts +85 -0
  206. package/dist/src/simmer.js +224 -0
  207. package/dist/src/simmer.js.map +1 -0
  208. package/dist/src/state-export.d.ts +129 -0
  209. package/dist/src/state-export.js +233 -0
  210. package/dist/src/state-export.js.map +1 -0
  211. package/dist/src/strategy.d.ts +54 -0
  212. package/dist/src/strategy.js +95 -0
  213. package/dist/src/strategy.js.map +1 -0
  214. package/dist/src/subscription.d.ts +40 -0
  215. package/dist/src/subscription.js +189 -0
  216. package/dist/src/subscription.js.map +1 -0
  217. package/dist/src/templates.d.ts +208 -0
  218. package/dist/src/templates.js +238 -0
  219. package/dist/src/templates.js.map +1 -0
  220. package/dist/src/test.d.ts +9 -0
  221. package/dist/src/test.js +1173 -0
  222. package/dist/src/test.js.map +1 -0
  223. package/dist/src/think-engine.d.ts +67 -0
  224. package/dist/src/think-engine.js +412 -0
  225. package/dist/src/think-engine.js.map +1 -0
  226. package/dist/src/tui.d.ts +71 -0
  227. package/dist/src/tui.js +242 -0
  228. package/dist/src/tui.js.map +1 -0
  229. package/dist/src/types.d.ts +177 -0
  230. package/dist/src/types.js +6 -0
  231. package/dist/src/types.js.map +1 -0
  232. package/dist/src/update-check.d.ts +7 -0
  233. package/dist/src/update-check.js +36 -0
  234. package/dist/src/update-check.js.map +1 -0
  235. package/dist/src/wave-planner.d.ts +30 -0
  236. package/dist/src/wave-planner.js +281 -0
  237. package/dist/src/wave-planner.js.map +1 -0
  238. package/hooks/head-guard.sh +41 -0
  239. package/hooks/task-classifier.mjs +328 -0
  240. package/hooks/vibe-router.mjs +387 -0
  241. package/package.json +29 -153
  242. package/src/agents/registry.mjs +0 -405
  243. package/src/awareness.mjs +0 -425
  244. package/src/brief.mjs +0 -266
  245. package/src/calibration.mjs +0 -148
  246. package/src/checkpoint.mjs +0 -109
  247. package/src/ci-triage.mjs +0 -191
  248. package/src/cognitive-loop.mjs +0 -562
  249. package/src/collaboration.mjs +0 -545
  250. package/src/context-intel.mjs +0 -158
  251. package/src/context.mjs +0 -389
  252. package/src/continuity.mjs +0 -298
  253. package/src/cost-tracker.mjs +0 -184
  254. package/src/debrief.mjs +0 -228
  255. package/src/decide.mjs +0 -1099
  256. package/src/decompose.mjs +0 -331
  257. package/src/detect.mjs +0 -702
  258. package/src/dispatch.mjs +0 -1447
  259. package/src/doctor.mjs +0 -1607
  260. package/src/envelope.mjs +0 -139
  261. package/src/failure-memory.mjs +0 -178
  262. package/src/fx.mjs +0 -276
  263. package/src/governance.mjs +0 -279
  264. package/src/handoff.mjs +0 -87
  265. package/src/head-protocol.mjs +0 -128
  266. package/src/head.mjs +0 -952
  267. package/src/health.mjs +0 -528
  268. package/src/inbox.mjs +0 -195
  269. package/src/index.mjs +0 -44
  270. package/src/install-hooks.mjs +0 -100
  271. package/src/integrity.mjs +0 -245
  272. package/src/intelligence.mjs +0 -447
  273. package/src/ledger.mjs +0 -196
  274. package/src/living-docs.mjs +0 -210
  275. package/src/memory-tiers.mjs +0 -193
  276. package/src/models.mjs +0 -363
  277. package/src/narrative.mjs +0 -169
  278. package/src/nextstep.mjs +0 -100
  279. package/src/observer.mjs +0 -241
  280. package/src/outcome.mjs +0 -400
  281. package/src/pipeline.mjs +0 -1711
  282. package/src/playbook.mjs +0 -257
  283. package/src/pr-agent.mjs +0 -214
  284. package/src/predictive.mjs +0 -250
  285. package/src/profile.mjs +0 -1411
  286. package/src/prompt-audit.mjs +0 -231
  287. package/src/prompt-intel.mjs +0 -325
  288. package/src/provider-context.mjs +0 -257
  289. package/src/receipt.mjs +0 -344
  290. package/src/recommendations.mjs +0 -296
  291. package/src/redact.mjs +0 -192
  292. package/src/replit.mjs +0 -1210
  293. package/src/repo.mjs +0 -445
  294. package/src/revert.mjs +0 -149
  295. package/src/routing-advisor.mjs +0 -204
  296. package/src/self-correct.mjs +0 -147
  297. package/src/session-lock.mjs +0 -160
  298. package/src/session.mjs +0 -1655
  299. package/src/settings-tui.mjs +0 -373
  300. package/src/setup-flow.mjs +0 -223
  301. package/src/signal.mjs +0 -115
  302. package/src/simmer.mjs +0 -241
  303. package/src/strategy.mjs +0 -235
  304. package/src/subscription.mjs +0 -212
  305. package/src/templates.mjs +0 -260
  306. package/src/think-engine.mjs +0 -428
  307. package/src/tui.mjs +0 -276
  308. package/src/update-check.mjs +0 -35
  309. 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
- }