dual-brain 0.2.30 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) 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/precompact.mjs +3 -3
  240. package/hooks/session-end.mjs +3 -3
  241. package/hooks/task-classifier.mjs +328 -0
  242. package/hooks/vibe-router.mjs +387 -0
  243. package/install.mjs +2 -2
  244. package/package.json +29 -153
  245. package/src/agents/registry.mjs +0 -405
  246. package/src/awareness.mjs +0 -425
  247. package/src/brief.mjs +0 -266
  248. package/src/calibration.mjs +0 -148
  249. package/src/checkpoint.mjs +0 -109
  250. package/src/ci-triage.mjs +0 -191
  251. package/src/cognitive-loop.mjs +0 -562
  252. package/src/collaboration.mjs +0 -545
  253. package/src/context-intel.mjs +0 -158
  254. package/src/context.mjs +0 -389
  255. package/src/continuity.mjs +0 -298
  256. package/src/cost-tracker.mjs +0 -184
  257. package/src/debrief.mjs +0 -228
  258. package/src/decide.mjs +0 -1099
  259. package/src/decompose.mjs +0 -331
  260. package/src/detect.mjs +0 -702
  261. package/src/dispatch.mjs +0 -1447
  262. package/src/doctor.mjs +0 -1607
  263. package/src/envelope.mjs +0 -139
  264. package/src/failure-memory.mjs +0 -178
  265. package/src/fx.mjs +0 -276
  266. package/src/governance.mjs +0 -279
  267. package/src/handoff.mjs +0 -87
  268. package/src/head-protocol.mjs +0 -128
  269. package/src/head.mjs +0 -952
  270. package/src/health.mjs +0 -528
  271. package/src/inbox.mjs +0 -195
  272. package/src/index.mjs +0 -44
  273. package/src/install-hooks.mjs +0 -100
  274. package/src/integrity.mjs +0 -245
  275. package/src/intelligence.mjs +0 -447
  276. package/src/ledger.mjs +0 -196
  277. package/src/living-docs.mjs +0 -210
  278. package/src/memory-tiers.mjs +0 -193
  279. package/src/models.mjs +0 -363
  280. package/src/narrative.mjs +0 -169
  281. package/src/nextstep.mjs +0 -100
  282. package/src/observer.mjs +0 -241
  283. package/src/outcome.mjs +0 -400
  284. package/src/pipeline.mjs +0 -1711
  285. package/src/playbook.mjs +0 -257
  286. package/src/pr-agent.mjs +0 -214
  287. package/src/predictive.mjs +0 -250
  288. package/src/profile.mjs +0 -1411
  289. package/src/prompt-audit.mjs +0 -231
  290. package/src/prompt-intel.mjs +0 -325
  291. package/src/provider-context.mjs +0 -257
  292. package/src/receipt.mjs +0 -344
  293. package/src/recommendations.mjs +0 -296
  294. package/src/redact.mjs +0 -192
  295. package/src/replit.mjs +0 -1210
  296. package/src/repo.mjs +0 -445
  297. package/src/revert.mjs +0 -149
  298. package/src/routing-advisor.mjs +0 -204
  299. package/src/self-correct.mjs +0 -147
  300. package/src/session-lock.mjs +0 -160
  301. package/src/session.mjs +0 -1655
  302. package/src/settings-tui.mjs +0 -373
  303. package/src/setup-flow.mjs +0 -223
  304. package/src/signal.mjs +0 -115
  305. package/src/simmer.mjs +0 -241
  306. package/src/strategy.mjs +0 -235
  307. package/src/subscription.mjs +0 -212
  308. package/src/templates.mjs +0 -260
  309. package/src/think-engine.mjs +0 -428
  310. package/src/tui.mjs +0 -276
  311. package/src/update-check.mjs +0 -35
  312. 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
- }