dual-brain 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +29 -143
- package/bin/dual-brain.mjs +216 -79
- package/package.json +10 -2
- package/src/dispatch.mjs +87 -2
- package/src/head.mjs +353 -0
- package/src/health.mjs +156 -0
- package/src/integrity.mjs +245 -0
- package/src/prompt-audit.mjs +231 -0
- package/src/templates.mjs +223 -0
- package/src/tui.mjs +79 -0
package/src/dispatch.mjs
CHANGED
|
@@ -527,6 +527,60 @@ function getRetryBudget() {
|
|
|
527
527
|
};
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
+
// ─── Preflight auth check ─────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Verify a provider CLI is present and (optionally) responds to --version.
|
|
534
|
+
* Uses `which` for the fast path and a 3s-capped --version call to confirm.
|
|
535
|
+
*
|
|
536
|
+
* @param {'claude'|'openai'} provider
|
|
537
|
+
* @param {string} [cwd] Working directory (unused, kept for signature parity)
|
|
538
|
+
* @returns {Promise<{ ready: boolean, provider: string, error?: string, suggestion?: string }>}
|
|
539
|
+
*/
|
|
540
|
+
async function preflightAuth(provider, _cwd) {
|
|
541
|
+
const bin = provider === 'openai' ? 'codex' : 'claude';
|
|
542
|
+
|
|
543
|
+
// Fast path: check binary existence with `which`
|
|
544
|
+
const whichResult = await new Promise((resolve) => {
|
|
545
|
+
const p = spawn('which', [bin], { stdio: 'pipe' });
|
|
546
|
+
p.on('error', () => resolve(false));
|
|
547
|
+
p.on('close', (code) => resolve(code === 0));
|
|
548
|
+
setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 2000);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (!whichResult) {
|
|
552
|
+
const installHint = provider === 'openai'
|
|
553
|
+
? 'Install: npm install -g @openai/codex'
|
|
554
|
+
: 'Install: npm install -g @anthropic-ai/claude-code';
|
|
555
|
+
return {
|
|
556
|
+
ready: false,
|
|
557
|
+
provider,
|
|
558
|
+
error: `${bin} CLI not found in PATH`,
|
|
559
|
+
suggestion: installHint,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Version check: confirms the binary actually runs (catches broken installs)
|
|
564
|
+
const versionOk = await new Promise((resolve) => {
|
|
565
|
+
const p = spawn(bin, ['--version'], { stdio: 'pipe' });
|
|
566
|
+
p.on('error', () => resolve(false));
|
|
567
|
+
p.on('close', (code) => resolve(code === 0));
|
|
568
|
+
setTimeout(() => { try { p.kill(); } catch {} resolve(false); }, 3000);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (!versionOk) {
|
|
572
|
+
const loginHint = provider === 'openai' ? 'Run: codex login' : 'Run: claude login';
|
|
573
|
+
return {
|
|
574
|
+
ready: false,
|
|
575
|
+
provider,
|
|
576
|
+
error: `${bin} --version failed (auth may have expired)`,
|
|
577
|
+
suggestion: loginHint,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return { ready: true, provider };
|
|
582
|
+
}
|
|
583
|
+
|
|
530
584
|
// ─── Command builder ──────────────────────────────────────────────────────────
|
|
531
585
|
|
|
532
586
|
function buildCommand(decision, prompt, files = [], _cwd) {
|
|
@@ -840,6 +894,34 @@ async function dispatch(input = {}) {
|
|
|
840
894
|
}
|
|
841
895
|
}
|
|
842
896
|
|
|
897
|
+
// ── Preflight auth check ─────────────────────────────────────────────────
|
|
898
|
+
// Verify the target provider CLI is present and responsive before dispatching.
|
|
899
|
+
// Runs after model/provider resolution so we check the effective provider.
|
|
900
|
+
const preflight = await preflightAuth(effectiveProvider, cwd);
|
|
901
|
+
if (!preflight.ready) {
|
|
902
|
+
// Check if the other provider is available as a fallback
|
|
903
|
+
const otherProvider = effectiveProvider === 'claude' ? 'openai' : 'claude';
|
|
904
|
+
const otherPreflight = await preflightAuth(otherProvider, cwd);
|
|
905
|
+
const fallbackNote = otherPreflight.ready
|
|
906
|
+
? ` Fallback available: ${otherProvider}.`
|
|
907
|
+
: '';
|
|
908
|
+
const errMsg = `${preflight.error}. ${preflight.suggestion}${fallbackNote}`;
|
|
909
|
+
return {
|
|
910
|
+
status: 'error',
|
|
911
|
+
provider: effectiveProvider,
|
|
912
|
+
model: effectiveModel,
|
|
913
|
+
command: null,
|
|
914
|
+
exitCode: null,
|
|
915
|
+
summary: errMsg,
|
|
916
|
+
durationMs: 0,
|
|
917
|
+
usage: null,
|
|
918
|
+
error: errMsg,
|
|
919
|
+
authVerified: false,
|
|
920
|
+
suggestion: preflight.suggestion,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
// ── End preflight auth check ─────────────────────────────────────────────
|
|
924
|
+
|
|
843
925
|
// ── Feature 2: Dirty-worktree guard for execute-tier dispatches ──────────
|
|
844
926
|
if (tier === 'execute' && decision.owns && !decision._force) {
|
|
845
927
|
const wtCheck = await checkWorktreeClean(decision.owns, cwd);
|
|
@@ -902,6 +984,7 @@ async function dispatch(input = {}) {
|
|
|
902
984
|
durationMs: 0,
|
|
903
985
|
usage: null,
|
|
904
986
|
error: null,
|
|
987
|
+
authVerified: true,
|
|
905
988
|
};
|
|
906
989
|
}
|
|
907
990
|
|
|
@@ -1014,6 +1097,7 @@ async function dispatch(input = {}) {
|
|
|
1014
1097
|
usage,
|
|
1015
1098
|
worktreeUsed: useWorktree,
|
|
1016
1099
|
autoReview,
|
|
1100
|
+
authVerified: true,
|
|
1017
1101
|
error: success ? null : errorText.slice(0, 200),
|
|
1018
1102
|
};
|
|
1019
1103
|
}
|
|
@@ -1021,7 +1105,7 @@ async function dispatch(input = {}) {
|
|
|
1021
1105
|
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
1022
1106
|
|
|
1023
1107
|
if (dryRun) {
|
|
1024
|
-
return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null };
|
|
1108
|
+
return { status: 'dry-run', provider: effectiveProvider, model: effectiveModel, specialist: specialist ?? 'generic', command, exitCode: null, summary: null, durationMs: 0, usage: null, error: null, authVerified: true };
|
|
1025
1109
|
}
|
|
1026
1110
|
|
|
1027
1111
|
// Record this dispatch against the budget
|
|
@@ -1130,6 +1214,7 @@ async function dispatch(input = {}) {
|
|
|
1130
1214
|
usage,
|
|
1131
1215
|
worktreeUsed: useWorktree,
|
|
1132
1216
|
autoReview,
|
|
1217
|
+
authVerified: true,
|
|
1133
1218
|
error: success ? null : errorText.slice(0, 200),
|
|
1134
1219
|
};
|
|
1135
1220
|
}
|
|
@@ -1221,4 +1306,4 @@ if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
|
|
|
1221
1306
|
}
|
|
1222
1307
|
}
|
|
1223
1308
|
|
|
1224
|
-
export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt };
|
|
1309
|
+
export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrain, validateDispatch, checkWorktreeClean, getRetryBudget, isInsideClaude, buildNativeDispatch, normalizeResult, loadSpecialistPrompt, preflightAuth };
|
package/src/head.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const STATE_FILE = join(process.cwd(), '.dualbrain', 'head-state.json');
|
|
5
|
+
|
|
6
|
+
// ── Conversation phases ──────────────────────────────────────────────────────
|
|
7
|
+
const PHASES = ['clarify', 'discuss', 'plan', 'dispatch', 'review', 'close'];
|
|
8
|
+
|
|
9
|
+
const VALID_TRANSITIONS = {
|
|
10
|
+
clarify: ['discuss', 'plan', 'close'],
|
|
11
|
+
discuss: ['plan', 'dispatch', 'clarify', 'close'],
|
|
12
|
+
plan: ['dispatch', 'discuss', 'close'],
|
|
13
|
+
dispatch: ['review', 'dispatch', 'close'],
|
|
14
|
+
review: ['dispatch', 'discuss', 'close'],
|
|
15
|
+
close: ['clarify'],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Intent classification ────────────────────────────────────────────────────
|
|
19
|
+
const INTENT_PATTERNS = {
|
|
20
|
+
information: [
|
|
21
|
+
/\b(what|where|which|how many|show me|list|find|search|grep|explain)\b/i,
|
|
22
|
+
/\?$/,
|
|
23
|
+
],
|
|
24
|
+
discussion: [
|
|
25
|
+
/\b(should we|what do you think|thoughts on|opinion|brainstorm|consider|tradeoff|approach)\b/i,
|
|
26
|
+
/\b(idea|strategy|philosophy|design|architecture)\b/i,
|
|
27
|
+
],
|
|
28
|
+
action: [
|
|
29
|
+
/\b(build|create|fix|implement|add|remove|update|refactor|deploy|publish|ship|go|do it)\b/i,
|
|
30
|
+
/\b(parallel agents|dispatch|bump|install)\b/i,
|
|
31
|
+
],
|
|
32
|
+
approval: [
|
|
33
|
+
/^(yes|y|ok|sure|do it|go|approved|lgtm|ship it)\s*$/i,
|
|
34
|
+
/\b(go ahead|sounds good|let's do it|proceed)\b/i,
|
|
35
|
+
],
|
|
36
|
+
correction: [
|
|
37
|
+
/\b(no|stop|wait|hold|wrong|not that|don't|shouldn't|instead)\b/i,
|
|
38
|
+
/\b(actually|but|however)\b/i,
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Classify user intent from their message.
|
|
44
|
+
* Returns { intent, confidence, signals }
|
|
45
|
+
*/
|
|
46
|
+
export function classifyIntent(message) {
|
|
47
|
+
const scores = { information: 0, discussion: 0, action: 0, approval: 0, correction: 0 };
|
|
48
|
+
const signals = [];
|
|
49
|
+
|
|
50
|
+
for (const [intent, patterns] of Object.entries(INTENT_PATTERNS)) {
|
|
51
|
+
for (const pattern of patterns) {
|
|
52
|
+
if (pattern.test(message)) {
|
|
53
|
+
scores[intent] += 1;
|
|
54
|
+
signals.push({ intent, pattern: pattern.source });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Short messages that are just "yes"/"go" are almost always approval
|
|
60
|
+
if (message.trim().split(/\s+/).length <= 3) {
|
|
61
|
+
scores.approval += 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find highest scoring intent
|
|
65
|
+
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
|
66
|
+
const top = sorted[0];
|
|
67
|
+
const second = sorted[1];
|
|
68
|
+
|
|
69
|
+
// Confidence based on margin between top two
|
|
70
|
+
const margin = top[1] - second[1];
|
|
71
|
+
const confidence = top[1] === 0 ? 0.3 : margin >= 2 ? 0.95 : margin >= 1 ? 0.8 : 0.6;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
intent: top[1] > 0 ? top[0] : 'unknown',
|
|
75
|
+
confidence,
|
|
76
|
+
scores,
|
|
77
|
+
signals,
|
|
78
|
+
ambiguous: confidence < 0.7,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Conversation state ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load current HEAD state from disk.
|
|
86
|
+
*/
|
|
87
|
+
export function loadState() {
|
|
88
|
+
try {
|
|
89
|
+
if (existsSync(STATE_FILE)) {
|
|
90
|
+
const data = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
91
|
+
// Reset stale sessions (>30 min gap)
|
|
92
|
+
if (Date.now() - (data.lastActivity || 0) > 30 * 60 * 1000) {
|
|
93
|
+
return freshState();
|
|
94
|
+
}
|
|
95
|
+
return data;
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
return freshState();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function freshState() {
|
|
102
|
+
return {
|
|
103
|
+
sessionId: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
104
|
+
phase: 'clarify',
|
|
105
|
+
intent: 'unknown',
|
|
106
|
+
confidence: 0,
|
|
107
|
+
userGoal: null,
|
|
108
|
+
activeTasks: [],
|
|
109
|
+
decisions: [],
|
|
110
|
+
contextEstimate: { messages: 0, estimatedTokens: 0, compactionRisk: 'low' },
|
|
111
|
+
driftSignals: [],
|
|
112
|
+
lastActivity: Date.now(),
|
|
113
|
+
created: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Save HEAD state to disk.
|
|
119
|
+
*/
|
|
120
|
+
export function saveState(state) {
|
|
121
|
+
state.lastActivity = Date.now();
|
|
122
|
+
mkdirSync(join(process.cwd(), '.dualbrain'), { recursive: true });
|
|
123
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Phase transitions ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Attempt a phase transition. Returns { allowed, from, to, reason? }
|
|
130
|
+
*/
|
|
131
|
+
export function transition(state, targetPhase) {
|
|
132
|
+
const from = state.phase;
|
|
133
|
+
const allowed = VALID_TRANSITIONS[from]?.includes(targetPhase) || false;
|
|
134
|
+
|
|
135
|
+
if (allowed) {
|
|
136
|
+
state.phase = targetPhase;
|
|
137
|
+
state.decisions.push({
|
|
138
|
+
type: 'phase-transition',
|
|
139
|
+
from,
|
|
140
|
+
to: targetPhase,
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
allowed,
|
|
147
|
+
from,
|
|
148
|
+
to: targetPhase,
|
|
149
|
+
reason: allowed ? null : `Cannot transition from ${from} to ${targetPhase}. Valid: ${(VALID_TRANSITIONS[from] || []).join(', ')}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Suggest the appropriate phase based on intent and current state.
|
|
155
|
+
*/
|
|
156
|
+
export function suggestPhase(state, intent) {
|
|
157
|
+
const map = {
|
|
158
|
+
information: state.phase === 'clarify' ? 'clarify' : 'discuss',
|
|
159
|
+
discussion: 'discuss',
|
|
160
|
+
action: state.phase === 'discuss' || state.phase === 'plan' ? 'dispatch' : 'plan',
|
|
161
|
+
approval: state.phase === 'plan' ? 'dispatch' : state.phase,
|
|
162
|
+
correction: state.phase === 'dispatch' ? 'review' : 'clarify',
|
|
163
|
+
unknown: 'clarify',
|
|
164
|
+
};
|
|
165
|
+
return map[intent] || 'clarify';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Confidence tracker ───────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Run the 4-question confidence check before dispatching.
|
|
172
|
+
* Returns { ready, score, checks }
|
|
173
|
+
*/
|
|
174
|
+
export function checkConfidence(state) {
|
|
175
|
+
const checks = {
|
|
176
|
+
understandIntent: {
|
|
177
|
+
pass: !!(state.userGoal && state.intent !== 'unknown'),
|
|
178
|
+
question: 'Do I understand the user\'s intent?',
|
|
179
|
+
},
|
|
180
|
+
discussedApproach: {
|
|
181
|
+
pass: state.decisions.some(d => d.type === 'phase-transition' && d.to === 'discuss') || state.phase === 'plan',
|
|
182
|
+
question: 'Have we discussed the approach?',
|
|
183
|
+
},
|
|
184
|
+
honestAboutUnknowns: {
|
|
185
|
+
pass: state.driftSignals.length === 0 || state.driftSignals.every(s => s.resolved),
|
|
186
|
+
question: 'Am I honest about unknowns?',
|
|
187
|
+
},
|
|
188
|
+
reversible: {
|
|
189
|
+
pass: true, // default; caller should override for high-risk
|
|
190
|
+
question: 'Is this reversible?',
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const passing = Object.values(checks).filter(c => c.pass).length;
|
|
195
|
+
const total = Object.values(checks).length;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
ready: passing === total,
|
|
199
|
+
score: passing / total,
|
|
200
|
+
passing,
|
|
201
|
+
total,
|
|
202
|
+
checks,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Drift detection ──────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if HEAD's current action is consistent with its declared phase.
|
|
210
|
+
*/
|
|
211
|
+
export function detectDrift(state, action) {
|
|
212
|
+
const signals = [];
|
|
213
|
+
|
|
214
|
+
// Acting while in discuss phase
|
|
215
|
+
if (state.phase === 'clarify' && action.type === 'dispatch') {
|
|
216
|
+
signals.push({ signal: 'dispatch-before-discuss', severity: 'high', msg: 'Dispatching work before discussing approach' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Dispatching without acceptance criteria
|
|
220
|
+
if (action.type === 'dispatch' && (!action.task?.acceptanceCriteria || action.task.acceptanceCriteria.length === 0)) {
|
|
221
|
+
signals.push({ signal: 'no-acceptance-criteria', severity: 'medium', msg: 'Dispatch without acceptance criteria' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// HEAD doing implementation work
|
|
225
|
+
if (['edit', 'write', 'bash-impl'].includes(action.type)) {
|
|
226
|
+
signals.push({ signal: 'head-implementing', severity: 'critical', msg: 'HEAD attempting direct implementation' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Repeated dispatch failures
|
|
230
|
+
const recentFailures = (state.activeTasks || []).filter(t => t.status === 'failed' && Date.now() - t.endedAt < 300000);
|
|
231
|
+
if (recentFailures.length >= 2) {
|
|
232
|
+
signals.push({ signal: 'repeated-failures', severity: 'high', msg: `${recentFailures.length} recent dispatch failures — consider changing approach` });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Context getting large
|
|
236
|
+
if (state.contextEstimate.estimatedTokens > 150000) {
|
|
237
|
+
signals.push({ signal: 'context-pressure', severity: 'medium', msg: 'Context estimate exceeding 150k tokens — compaction risk' });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (signals.length > 0) {
|
|
241
|
+
state.driftSignals.push(...signals.map(s => ({ ...s, timestamp: Date.now(), resolved: false })));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return signals;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Context budget ───────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Update context estimate. Called after each turn.
|
|
251
|
+
*/
|
|
252
|
+
export function updateContextEstimate(state, opts = {}) {
|
|
253
|
+
const { messageCount, lastResponseTokens } = opts;
|
|
254
|
+
|
|
255
|
+
if (messageCount) state.contextEstimate.messages = messageCount;
|
|
256
|
+
if (lastResponseTokens) state.contextEstimate.estimatedTokens += lastResponseTokens;
|
|
257
|
+
|
|
258
|
+
// Rough compaction risk
|
|
259
|
+
const tokens = state.contextEstimate.estimatedTokens;
|
|
260
|
+
state.contextEstimate.compactionRisk =
|
|
261
|
+
tokens > 180000 ? 'critical' :
|
|
262
|
+
tokens > 120000 ? 'high' :
|
|
263
|
+
tokens > 80000 ? 'medium' : 'low';
|
|
264
|
+
|
|
265
|
+
return state.contextEstimate;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Task tracking ────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Register a dispatched task.
|
|
272
|
+
*/
|
|
273
|
+
export function trackTask(state, task) {
|
|
274
|
+
state.activeTasks.push({
|
|
275
|
+
id: task.id || Date.now().toString(36),
|
|
276
|
+
objective: task.objective,
|
|
277
|
+
tier: task.tier,
|
|
278
|
+
provider: task.provider,
|
|
279
|
+
status: 'dispatched',
|
|
280
|
+
startedAt: Date.now(),
|
|
281
|
+
endedAt: null,
|
|
282
|
+
result: null,
|
|
283
|
+
});
|
|
284
|
+
return state;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Update a task's status.
|
|
289
|
+
*/
|
|
290
|
+
export function completeTask(state, taskId, result) {
|
|
291
|
+
const task = state.activeTasks.find(t => t.id === taskId);
|
|
292
|
+
if (task) {
|
|
293
|
+
task.status = result.success ? 'completed' : 'failed';
|
|
294
|
+
task.endedAt = Date.now();
|
|
295
|
+
task.result = result;
|
|
296
|
+
}
|
|
297
|
+
return state;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Decision logging ─────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Log a HEAD decision.
|
|
304
|
+
*/
|
|
305
|
+
export function logDecision(state, decision) {
|
|
306
|
+
state.decisions.push({
|
|
307
|
+
...decision,
|
|
308
|
+
timestamp: Date.now(),
|
|
309
|
+
});
|
|
310
|
+
return state;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Convenience: process a user message ──────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Full turn processor: classify intent, suggest phase, detect drift, update state.
|
|
317
|
+
* Returns guidance for HEAD on what to do next.
|
|
318
|
+
*/
|
|
319
|
+
export function processTurn(state, userMessage) {
|
|
320
|
+
const intent = classifyIntent(userMessage);
|
|
321
|
+
const suggestedPhase = suggestPhase(state, intent.intent);
|
|
322
|
+
|
|
323
|
+
state.intent = intent.intent;
|
|
324
|
+
state.confidence = intent.confidence;
|
|
325
|
+
|
|
326
|
+
// Auto-transition if the suggested phase is valid
|
|
327
|
+
const transitionResult = suggestedPhase !== state.phase
|
|
328
|
+
? transition(state, suggestedPhase)
|
|
329
|
+
: { allowed: true, from: state.phase, to: state.phase };
|
|
330
|
+
|
|
331
|
+
// Check confidence if we're about to dispatch
|
|
332
|
+
const confidenceCheck = suggestedPhase === 'dispatch' ? checkConfidence(state) : null;
|
|
333
|
+
|
|
334
|
+
// Build guidance
|
|
335
|
+
const guidance = {
|
|
336
|
+
intent,
|
|
337
|
+
phase: state.phase,
|
|
338
|
+
suggestedPhase,
|
|
339
|
+
transitioned: transitionResult.allowed && transitionResult.from !== transitionResult.to,
|
|
340
|
+
confidenceCheck,
|
|
341
|
+
shouldDispatch: suggestedPhase === 'dispatch' && (!confidenceCheck || confidenceCheck.ready),
|
|
342
|
+
shouldClarify: intent.ambiguous || intent.intent === 'unknown',
|
|
343
|
+
shouldDiscuss: intent.intent === 'discussion' || (suggestedPhase === 'dispatch' && confidenceCheck && !confidenceCheck.ready),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
saveState(state);
|
|
347
|
+
return guidance;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Exports summary ──────────────────────────────────────────────────────────
|
|
351
|
+
// classifyIntent, loadState, saveState, freshState (via loadState),
|
|
352
|
+
// transition, suggestPhase, checkConfidence, detectDrift,
|
|
353
|
+
// updateContextEstimate, trackTask, completeTask, logDecision, processTurn
|
package/src/health.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
14
15
|
|
|
15
16
|
// ─── Auth status (delegates to replit-tools when available) ──────────────────
|
|
16
17
|
|
|
@@ -370,3 +371,158 @@ export function remainingCooldownMinutes(provider, modelClass, cwd) {
|
|
|
370
371
|
const remaining = cooldownMs - elapsedMs;
|
|
371
372
|
return remaining > 0 ? Math.ceil(remaining / 60_000) : 0;
|
|
372
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
|
+
}
|