dual-brain 0.2.8 → 0.2.10

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/src/head.mjs CHANGED
@@ -1,353 +1,795 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
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'],
4
+ const STATE_DIR = join(process.cwd(), '.dualbrain');
5
+ const STATE_FILE = join(STATE_DIR, 'head-state.json');
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // HEAD — Cognitive Judgment Pipeline
9
+ //
10
+ // Five artifacts flow through every turn:
11
+ // perceive assess uncertainty → derive obligations → notice → deliberate
12
+ //
13
+ // SituationModel: what's happening (replaces classifyIntent)
14
+ // UncertaintyLedger: what HEAD knows vs suspects vs lacks (replaces checkConfidence)
15
+ // CareObligations: what HEAD is responsible for (replaces phase transitions)
16
+ // Noticings: what HEAD observes passively (replaces detectDrift)
17
+ // DeliberationResult: what HEAD decides to do and why (replaces processTurn)
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+
20
+ // ── Values: these shape judgment, not rules to check ────────────────────────
21
+
22
+ export const HEAD_VALUES = {
23
+ selfHonesty: 'Say what you don\'t know. Never dress up guesses as facts.',
24
+ materialCare: 'The user\'s code, context, and time are precious. Don\'t waste them.',
25
+ curiosity: 'Notice what\'s off. Ask what you\'re not seeing.',
26
+ strategicPace: 'Know when to act fast and when to slow down.',
27
+ proactivity: 'Surface things the user should know, but only when it matters.',
28
+ restraint: 'Can do ≠ should do. Permission ≠ wisdom.',
29
+ honesty: 'Be honest about the material — its quality, risks, and gaps.',
30
+ consideration: 'Think about the user\'s actual situation, not the abstract task.',
16
31
  };
17
32
 
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
- ],
33
+ // ── Depth assessment: how much cognition does this deserve? ─────────────────
34
+
35
+ const DEPTH_SIGNALS = {
36
+ ambiguity: { weight: 3, test: (s) => s.ambiguity },
37
+ risk: { weight: 4, test: (s) => s.risk },
38
+ irreversibility: { weight: 4, test: (s) => s.reversibility === 'hard' ? 'high' : s.reversibility === 'moderate' ? 'medium' : 'low' },
39
+ scope: { weight: 2, test: (s) => s.scope === 'large' ? 'high' : s.scope === 'medium' ? 'medium' : 'low' },
40
+ priorFailures: { weight: 3, test: (s) => (s.priorFailures || 0) >= 2 ? 'high' : s.priorFailures >= 1 ? 'medium' : 'low' },
41
+ novelty: { weight: 2, test: (s) => s.novelty },
42
+ materialValue: { weight: 3, test: (s) => s.materialValue },
43
+ userStress: { weight: 2, test: (s) => s.userStress },
44
+ contextVolatility: { weight: 1, test: (s) => s.contextVolatility },
40
45
  };
41
46
 
47
+ const LEVEL_SCORES = { low: 0, medium: 1, high: 2, critical: 3 };
48
+
42
49
  /**
43
- * Classify user intent from their message.
44
- * Returns { intent, confidence, signals }
50
+ * Assess how much deliberation this situation deserves.
51
+ * Returns 'reflexive' | 'light' | 'full' | 'deep'
52
+ *
53
+ * Reflexive: instant response, no deliberation (simple questions, greetings)
54
+ * Light: quick judgment, check obligations (standard tasks)
55
+ * Full: structured deliberation with uncertainty + obligations (complex/risky)
56
+ * Deep: full pipeline + pause for user input (ambiguous, novel, high-stakes)
45
57
  */
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
- }
58
+ export function assessDepth(signals) {
59
+ let score = 0;
60
+ for (const [, cfg] of Object.entries(DEPTH_SIGNALS)) {
61
+ const level = cfg.test(signals) || 'low';
62
+ score += (LEVEL_SCORES[level] || 0) * cfg.weight;
57
63
  }
58
64
 
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
- }
65
+ if (score <= 4) return 'reflexive';
66
+ if (score <= 12) return 'light';
67
+ if (score <= 22) return 'full';
68
+ return 'deep';
69
+ }
63
70
 
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];
71
+ // ── SituationModel: what's happening ────────────────────────────────────────
68
72
 
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;
73
+ /**
74
+ * Build a situation model from user input and context.
75
+ * This replaces classifyIntent instead of a label, HEAD gets a full picture.
76
+ */
77
+ export function perceive(message, context = {}) {
78
+ const words = message.trim().split(/\s+/);
79
+ const isQuestion = /\?\s*$/.test(message.trim());
80
+ const isShort = words.length <= 5;
81
+
82
+ // Infer task shape from content, not regex labels
83
+ const taskShape = _inferTaskShape(message, context);
84
+
85
+ // Detect what the user is actually asking for vs what they said
86
+ const inferredGoal = _inferGoal(message, context);
87
+
88
+ // Detect urgency from language and context
89
+ const urgency = _assessUrgency(message, context);
90
+
91
+ // Material awareness — what code/files are relevant
92
+ const material = _assessMaterial(message, context);
93
+
94
+ // Relationship signals — should HEAD ask, act, or advise?
95
+ const relationship = _assessRelationship(message, context, taskShape);
72
96
 
73
97
  return {
74
- intent: top[1] > 0 ? top[0] : 'unknown',
75
- confidence,
76
- scores,
77
- signals,
78
- ambiguous: confidence < 0.7,
98
+ raw: message,
99
+ explicitAsk: message.trim(),
100
+ inferredGoal,
101
+ urgency,
102
+ isQuestion,
103
+ isShort,
104
+
105
+ taskShape,
106
+ material,
107
+ relationship,
108
+
109
+ // Depth signals for adaptive processing
110
+ ambiguity: taskShape.ambiguity,
111
+ risk: taskShape.risk,
112
+ reversibility: taskShape.reversibility,
113
+ scope: taskShape.scope,
114
+ novelty: context.novelty || 'low',
115
+ materialValue: material.value,
116
+ userStress: urgency === 'high' ? 'high' : 'low',
117
+ contextVolatility: context.volatility || 'low',
118
+ priorFailures: context.priorFailures || 0,
79
119
  };
80
120
  }
81
121
 
82
- // ── Conversation state ───────────────────────────────────────────────────────
122
+ function _inferTaskShape(message, context) {
123
+ const lower = message.toLowerCase();
124
+
125
+ // Scope: how big is this?
126
+ const fileCount = context.files?.length || 0;
127
+ const scope = fileCount > 5 ? 'large' : fileCount > 2 ? 'medium' : lower.length > 500 ? 'medium' : 'small';
128
+
129
+ // Risk: what could go wrong?
130
+ const riskSignals = [];
131
+ if (/\b(auth|secret|token|credential|password|key|session|permission)\b/i.test(message)) riskSignals.push('security-adjacent');
132
+ if (/\b(delete|remove|drop|destroy|reset|force|wipe)\b/i.test(message)) riskSignals.push('destructive-language');
133
+ if (/\b(deploy|publish|push|release|ship|migrate)\b/i.test(message)) riskSignals.push('external-effect');
134
+ if (/\b(database|db|schema|migration|table)\b/i.test(message)) riskSignals.push('data-mutation');
135
+ if (context.priorFailures >= 2) riskSignals.push('repeated-failure');
136
+
137
+ const risk = riskSignals.length >= 3 ? 'critical'
138
+ : riskSignals.length >= 2 ? 'high'
139
+ : riskSignals.length >= 1 ? 'medium'
140
+ : 'low';
141
+
142
+ // Reversibility: can this be undone?
143
+ const hasDestructive = riskSignals.includes('destructive-language') || riskSignals.includes('external-effect');
144
+ const reversibility = hasDestructive ? 'hard' : riskSignals.includes('data-mutation') ? 'moderate' : 'easy';
145
+
146
+ // Ambiguity: how clear is the request?
147
+ const ambiguitySignals = [];
148
+ if (/\b(maybe|might|could|should we|not sure|thinking about|what if|somehow)\b/i.test(message)) ambiguitySignals.push('hedging-language');
149
+ if (/\b(or|versus|vs|either|option|alternative)\b/i.test(message)) ambiguitySignals.push('considering-alternatives');
150
+ if (message.split('?').length > 2) ambiguitySignals.push('multiple-questions');
151
+ if (!context.files?.length && /\b(it|this|that|these|those)\b/i.test(message) && !context.recentFiles?.length) ambiguitySignals.push('vague-reference');
152
+
153
+ const ambiguity = ambiguitySignals.length >= 2 ? 'high' : ambiguitySignals.length >= 1 ? 'medium' : 'low';
154
+
155
+ // Type: what kind of work is this?
156
+ let type = 'unknown';
157
+ if (/\b(what|where|which|how many|show|list|find|search|explain|tell me)\b/i.test(message) || /\?\s*$/.test(message.trim())) type = 'answer';
158
+ if (/\b(fix|bug|error|broken|crash|fail|issue|wrong)\b/i.test(message)) type = 'debug';
159
+ if (/\b(build|create|add|implement|write|make|new)\b/i.test(message)) type = 'edit';
160
+ if (/\b(review|check|audit|look at|inspect)\b/i.test(message)) type = 'review';
161
+ if (/\b(research|investigate|explore|understand|dig into)\b/i.test(message)) type = 'research';
162
+ if (/\b(plan|design|architect|strategy|approach|think about|brainstorm)\b/i.test(message)) type = 'plan';
163
+ if (/\b(refactor|clean|reorganize|restructure|simplify)\b/i.test(message)) type = 'edit';
164
+
165
+ return { type, scope, risk, reversibility, ambiguity, riskSignals, ambiguitySignals };
166
+ }
83
167
 
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();
168
+ function _inferGoal(message, context) {
169
+ // When the explicit ask might not match the real need
170
+ const lower = message.toLowerCase();
171
+
172
+ // "Fix the tests" when the real problem might be the code, not the tests
173
+ if (/fix.*(test|spec)/i.test(message) && context.recentFailures?.length) {
174
+ return 'May need to fix source code, not just tests';
175
+ }
176
+
177
+ // "Make it work" — needs clarification
178
+ if (/\b(make it work|just work|get it working)\b/i.test(message)) {
179
+ return 'Vague success criteria — needs clarification on what "working" means';
180
+ }
181
+
182
+ // "Do everything" — scope needs bounding
183
+ if (/\b(do everything|all of it|everything)\b/i.test(message)) {
184
+ return 'Unbounded scope — needs prioritization';
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ function _assessUrgency(message, context) {
191
+ if (/\b(asap|urgent|now|immediately|hurry|quick|fast)\b/i.test(message)) return 'high';
192
+ if (/\b(when you get a chance|no rush|whenever|eventually)\b/i.test(message)) return 'low';
193
+ if (context.priorFailures >= 2) return 'high';
194
+ return 'medium';
99
195
  }
100
196
 
101
- function freshState() {
197
+ function _assessMaterial(message, context) {
198
+ const touchedFiles = context.files || [];
199
+ const fragileAreas = [];
200
+ const existingPatterns = context.patterns || [];
201
+
202
+ // Detect fragile areas from file paths
203
+ for (const f of touchedFiles) {
204
+ if (/auth|session|token|secret|credential/i.test(f)) fragileAreas.push({ file: f, reason: 'security-sensitive' });
205
+ if (/migration|schema|database/i.test(f)) fragileAreas.push({ file: f, reason: 'data-layer' });
206
+ if (/config|env|settings/i.test(f)) fragileAreas.push({ file: f, reason: 'configuration' });
207
+ }
208
+
209
+ const value = fragileAreas.length >= 2 ? 'high'
210
+ : fragileAreas.length >= 1 ? 'medium'
211
+ : touchedFiles.length > 5 ? 'medium'
212
+ : 'low';
213
+
102
214
  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(),
215
+ touchedFiles,
216
+ fragileAreas,
217
+ existingPatterns,
218
+ value,
219
+ userOwnedChanges: context.uncommittedFiles || [],
114
220
  };
115
221
  }
116
222
 
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));
223
+ function _assessRelationship(message, context, taskShape) {
224
+ // Should HEAD ask before acting?
225
+ const shouldAsk = taskShape.ambiguity === 'high'
226
+ || taskShape.risk === 'critical'
227
+ || taskShape.reversibility === 'hard'
228
+ || (taskShape.scope === 'large' && taskShape.ambiguity !== 'low');
229
+
230
+ // Is there likely a mismatch between what was asked and what's needed?
231
+ const likelyMismatch = !!(
232
+ (taskShape.type === 'debug' && context.priorFailures >= 2)
233
+ || (taskShape.ambiguity === 'high' && taskShape.risk !== 'low')
234
+ );
235
+
236
+ // Might the user be assuming something wrong?
237
+ const wrongAssumption = !!(
238
+ context.staleContext
239
+ || (context.priorFailures >= 2 && taskShape.type === 'debug')
240
+ );
241
+
242
+ return { shouldAsk, likelyMismatch, wrongAssumption };
124
243
  }
125
244
 
126
- // ── Phase transitions ────────────────────────────────────────────────────────
245
+ // ── UncertaintyLedger: what HEAD knows vs doesn't ──────────────────────────
127
246
 
128
247
  /**
129
- * Attempt a phase transition. Returns { allowed, from, to, reason? }
248
+ * Build an uncertainty ledger from the situation model.
249
+ * Each entry: a claim, how confident HEAD is, what the evidence is,
250
+ * and what would change HEAD's mind.
130
251
  */
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(),
252
+ export function assessUncertainty(situation, context = {}) {
253
+ const ledger = [];
254
+
255
+ // Scope certainty
256
+ if (situation.taskShape.scope !== 'small') {
257
+ ledger.push({
258
+ claim: 'The change is contained to the identified files',
259
+ confidence: situation.material.touchedFiles.length > 0 ? 0.7 : 0.3,
260
+ basis: situation.material.touchedFiles.length > 0
261
+ ? `${situation.material.touchedFiles.length} files identified`
262
+ : 'No files explicitly identified — scope unknown',
263
+ wouldChangeIf: 'Dependency analysis reveals cross-cutting concerns',
142
264
  });
143
265
  }
144
266
 
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
- }
267
+ // Risk assessment certainty
268
+ if (situation.taskShape.risk !== 'low') {
269
+ ledger.push({
270
+ claim: `Risk level is ${situation.taskShape.risk}`,
271
+ confidence: situation.taskShape.riskSignals.length >= 2 ? 0.85 : 0.6,
272
+ basis: `Signals: ${situation.taskShape.riskSignals.join(', ') || 'none'}`,
273
+ wouldChangeIf: 'Closer inspection reveals the risky-looking code is actually isolated/tested',
274
+ });
275
+ }
152
276
 
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
- }
277
+ // Goal understanding certainty
278
+ if (situation.inferredGoal) {
279
+ ledger.push({
280
+ claim: 'I understand what the user actually needs',
281
+ confidence: 0.5,
282
+ basis: `Explicit: "${situation.explicitAsk.slice(0, 80)}" but inferred: "${situation.inferredGoal}"`,
283
+ wouldChangeIf: 'User clarifies their actual goal',
284
+ });
285
+ } else if (situation.taskShape.ambiguity === 'high') {
286
+ ledger.push({
287
+ claim: 'I understand the request',
288
+ confidence: 0.4,
289
+ basis: `Ambiguity signals: ${situation.taskShape.ambiguitySignals.join(', ')}`,
290
+ wouldChangeIf: 'User provides more specific criteria or constraints',
291
+ });
292
+ }
167
293
 
168
- // ── Confidence tracker ───────────────────────────────────────────────────────
294
+ // Prior failure uncertainty
295
+ if (situation.priorFailures >= 1) {
296
+ ledger.push({
297
+ claim: 'The same approach will work this time',
298
+ confidence: Math.max(0.1, 0.8 - (situation.priorFailures * 0.25)),
299
+ basis: `${situation.priorFailures} prior failure(s) on similar task`,
300
+ wouldChangeIf: 'A fundamentally different approach is tried',
301
+ });
302
+ }
303
+
304
+ // Material safety
305
+ if (situation.material.fragileAreas.length > 0) {
306
+ ledger.push({
307
+ claim: 'Changes won\'t break existing functionality',
308
+ confidence: 0.5,
309
+ basis: `Fragile areas: ${situation.material.fragileAreas.map(a => a.file).join(', ')}`,
310
+ wouldChangeIf: 'Tests exist and pass for the affected areas',
311
+ });
312
+ }
313
+
314
+ // Context freshness
315
+ if (context.contextAge === 'stale') {
316
+ ledger.push({
317
+ claim: 'My understanding of the codebase is current',
318
+ confidence: 0.3,
319
+ basis: 'Context may be outdated — files could have changed since last read',
320
+ wouldChangeIf: 'Fresh file reads confirm current state',
321
+ });
322
+ }
323
+
324
+ return ledger;
325
+ }
169
326
 
170
327
  /**
171
- * Run the 4-question confidence check before dispatching.
172
- * Returns { ready, score, checks }
328
+ * Overall confidence from the uncertainty ledger.
329
+ * Not a boolean a nuanced picture.
173
330
  */
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
- };
331
+ export function summarizeConfidence(ledger) {
332
+ if (ledger.length === 0) return { level: 'sufficient', score: 0.8, gaps: [] };
193
333
 
194
- const passing = Object.values(checks).filter(c => c.pass).length;
195
- const total = Object.values(checks).length;
334
+ const avg = ledger.reduce((sum, e) => sum + e.confidence, 0) / ledger.length;
335
+ const gaps = ledger.filter(e => e.confidence < 0.5);
336
+ const blockers = ledger.filter(e => e.confidence < 0.3);
196
337
 
197
338
  return {
198
- ready: passing === total,
199
- score: passing / total,
200
- passing,
201
- total,
202
- checks,
339
+ level: blockers.length > 0 ? 'insufficient' : gaps.length > 0 ? 'partial' : 'sufficient',
340
+ score: Math.round(avg * 100) / 100,
341
+ gaps: gaps.map(g => g.claim),
342
+ blockers: blockers.map(b => ({ claim: b.claim, wouldResolve: b.wouldChangeIf })),
343
+ entryCount: ledger.length,
203
344
  };
204
345
  }
205
346
 
206
- // ── Drift detection ──────────────────────────────────────────────────────────
347
+ // ── CareObligations: what HEAD is responsible for ──────────────────────────
348
+
349
+ const OBLIGATION_TYPES = {
350
+ preserveWork: { priority: 'critical', description: 'Don\'t destroy the user\'s uncommitted work' },
351
+ respectPatterns: { priority: 'high', description: 'Follow the codebase\'s existing patterns and conventions' },
352
+ minimizeBlast: { priority: 'high', description: 'Keep changes as small and focused as possible' },
353
+ verifyBeforeClaim:{ priority: 'high', description: 'Don\'t claim success without evidence' },
354
+ askBeforeIrreversi:{ priority: 'critical', description: 'Get permission before irreversible actions' },
355
+ distinguishIntent:{ priority: 'medium', description: 'Separate what the user asked from what might also be useful' },
356
+ protectSecrets: { priority: 'critical', description: 'Never expose, log, or transmit secrets' },
357
+ honestLimits: { priority: 'high', description: 'Admit when you don\'t know or aren\'t sure' },
358
+ contextCare: { priority: 'medium', description: 'Be economical with context — don\'t waste tokens on ceremony' },
359
+ timingAwareness: { priority: 'medium', description: 'Sense whether now is the right time to surface something' },
360
+ };
207
361
 
208
362
  /**
209
- * Check if HEAD's current action is consistent with its declared phase.
363
+ * Derive which care obligations are active given the current situation.
210
364
  */
211
- export function detectDrift(state, action) {
212
- const signals = [];
365
+ export function deriveObligations(situation) {
366
+ const active = [];
213
367
 
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' });
368
+ // Always active
369
+ active.push({ ...OBLIGATION_TYPES.protectSecrets, type: 'protectSecrets', trigger: 'always' });
370
+ active.push({ ...OBLIGATION_TYPES.honestLimits, type: 'honestLimits', trigger: 'always' });
371
+ active.push({ ...OBLIGATION_TYPES.contextCare, type: 'contextCare', trigger: 'always' });
372
+
373
+ // Conditional obligations
374
+ if (situation.material.userOwnedChanges?.length > 0) {
375
+ active.push({ ...OBLIGATION_TYPES.preserveWork, type: 'preserveWork', trigger: `${situation.material.userOwnedChanges.length} uncommitted files` });
217
376
  }
218
377
 
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' });
378
+ if (situation.material.existingPatterns?.length > 0) {
379
+ active.push({ ...OBLIGATION_TYPES.respectPatterns, type: 'respectPatterns', trigger: `${situation.material.existingPatterns.length} existing patterns detected` });
222
380
  }
223
381
 
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' });
382
+ if (situation.taskShape.scope !== 'small' || situation.taskShape.risk !== 'low') {
383
+ active.push({ ...OBLIGATION_TYPES.minimizeBlast, type: 'minimizeBlast', trigger: `scope=${situation.taskShape.scope}, risk=${situation.taskShape.risk}` });
227
384
  }
228
385
 
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` });
386
+ if (situation.taskShape.type === 'edit' || situation.taskShape.type === 'debug') {
387
+ active.push({ ...OBLIGATION_TYPES.verifyBeforeClaim, type: 'verifyBeforeClaim', trigger: `task type: ${situation.taskShape.type}` });
233
388
  }
234
389
 
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' });
390
+ if (situation.taskShape.reversibility === 'hard' || situation.taskShape.risk === 'critical') {
391
+ active.push({ ...OBLIGATION_TYPES.askBeforeIrreversi, type: 'askBeforeIrreversi', trigger: `reversibility=${situation.taskShape.reversibility}, risk=${situation.taskShape.risk}` });
238
392
  }
239
393
 
240
- if (signals.length > 0) {
241
- state.driftSignals.push(...signals.map(s => ({ ...s, timestamp: Date.now(), resolved: false })));
394
+ if (situation.inferredGoal) {
395
+ active.push({ ...OBLIGATION_TYPES.distinguishIntent, type: 'distinguishIntent', trigger: `inferred goal differs: "${situation.inferredGoal}"` });
242
396
  }
243
397
 
244
- return signals;
398
+ return active;
245
399
  }
246
400
 
247
- // ── Context budget ───────────────────────────────────────────────────────────
401
+ // ── Noticings: what HEAD observes passively ─────────────────────────────────
248
402
 
249
403
  /**
250
- * Update context estimate. Called after each turn.
404
+ * Passive observation layer.
405
+ * Runs on every turn — detects things the user hasn't asked about.
406
+ * Noticings are internal. Deliberation decides whether to surface them.
251
407
  */
252
- export function updateContextEstimate(state, opts = {}) {
253
- const { messageCount, lastResponseTokens } = opts;
408
+ export function notice(situation, state, context = {}) {
409
+ const noticings = [];
410
+
411
+ // Drift: are we doing something different from what was discussed?
412
+ if (state.declaredGoal && situation.inferredGoal && state.declaredGoal !== situation.inferredGoal) {
413
+ noticings.push({
414
+ type: 'drift',
415
+ severity: 'medium',
416
+ observation: `Started with "${state.declaredGoal}" but current request implies "${situation.inferredGoal}"`,
417
+ shouldSurface: true,
418
+ });
419
+ }
420
+
421
+ // Repeated failure: same approach failing
422
+ if (situation.priorFailures >= 2) {
423
+ noticings.push({
424
+ type: 'pattern',
425
+ severity: 'high',
426
+ observation: `${situation.priorFailures} prior failures — the approach may be wrong, not just the execution`,
427
+ shouldSurface: true,
428
+ });
429
+ }
430
+
431
+ // Fragile area being touched without tests
432
+ for (const area of situation.material.fragileAreas) {
433
+ if (!context.hasTests?.[area.file]) {
434
+ noticings.push({
435
+ type: 'risk',
436
+ severity: 'medium',
437
+ observation: `${area.file} is ${area.reason} but has no test coverage`,
438
+ shouldSurface: situation.taskShape.type === 'edit',
439
+ });
440
+ }
441
+ }
442
+
443
+ // Context getting large
444
+ if (state.contextEstimate?.estimatedTokens > 120_000) {
445
+ const pct = Math.round((state.contextEstimate.estimatedTokens / 200_000) * 100);
446
+ noticings.push({
447
+ type: 'resource',
448
+ severity: state.contextEstimate.estimatedTokens > 160_000 ? 'high' : 'medium',
449
+ observation: `Context is at ~${pct}% capacity — consider wrapping up or handing off`,
450
+ shouldSurface: true,
451
+ });
452
+ }
453
+
454
+ // Scope creep: task growing beyond original ask
455
+ if (state.originalScope && situation.material.touchedFiles.length > state.originalScope * 2) {
456
+ noticings.push({
457
+ type: 'scope',
458
+ severity: 'medium',
459
+ observation: `Task has grown from ${state.originalScope} to ${situation.material.touchedFiles.length} files`,
460
+ shouldSurface: true,
461
+ });
462
+ }
254
463
 
255
- if (messageCount) state.contextEstimate.messages = messageCount;
256
- if (lastResponseTokens) state.contextEstimate.estimatedTokens += lastResponseTokens;
464
+ // Stale assumptions: acting on old information
465
+ if (context.contextAge === 'stale') {
466
+ noticings.push({
467
+ type: 'staleness',
468
+ severity: 'medium',
469
+ observation: 'Working from potentially outdated context — files may have changed',
470
+ shouldSurface: situation.taskShape.risk !== 'low',
471
+ });
472
+ }
257
473
 
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';
474
+ // Opportunity: something useful HEAD noticed
475
+ if (context.opportunities?.length) {
476
+ for (const opp of context.opportunities.slice(0, 3)) {
477
+ noticings.push({
478
+ type: 'opportunity',
479
+ severity: 'low',
480
+ observation: opp,
481
+ shouldSurface: false, // opportunities are internal unless deliberation promotes them
482
+ });
483
+ }
484
+ }
264
485
 
265
- return state.contextEstimate;
486
+ return noticings;
266
487
  }
267
488
 
268
- // ── Task tracking ────────────────────────────────────────────────────────────
489
+ // ── Deliberation: what HEAD decides to do ──────────────────────────────────
269
490
 
270
491
  /**
271
- * Register a dispatched task.
492
+ * Full deliberation pipeline.
493
+ * Produces a structured decision with rationale — not just an action label.
272
494
  */
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,
495
+ export function deliberate(situation, uncertaintyLedger, obligations, noticings, state) {
496
+ const depth = assessDepth(situation);
497
+ const confidence = summarizeConfidence(uncertaintyLedger);
498
+
499
+ // ── Reflexive: instant response, minimal processing
500
+ if (depth === 'reflexive' && confidence.level === 'sufficient') {
501
+ return {
502
+ depth,
503
+ action: _reflexiveAction(situation),
504
+ rationale: 'Simple request with sufficient confidence',
505
+ confidence,
506
+ obligations: obligations.filter(o => o.priority === 'critical'),
507
+ surfaceNoticings: [],
508
+ shouldAskUser: false,
509
+ uncertainties: [],
510
+ };
511
+ }
512
+
513
+ // ── Which noticings to surface?
514
+ const surfaceNoticings = noticings.filter(n => {
515
+ if (!n.shouldSurface) return false;
516
+ // Care obligation: timingAwareness — only surface if it's relevant right now
517
+ if (n.type === 'opportunity') return situation.taskShape.type === 'plan';
518
+ if (n.severity === 'high') return true;
519
+ if (n.severity === 'medium' && depth !== 'light') return true;
520
+ return false;
283
521
  });
284
- return state;
522
+
523
+ // ── Should HEAD ask the user before acting?
524
+ const shouldAskUser = _shouldAsk(situation, confidence, obligations, depth);
525
+
526
+ // ── Generate candidate actions
527
+ const candidates = _generateCandidates(situation, confidence, obligations);
528
+
529
+ // ── Select best action through obligation-weighted judgment
530
+ const chosen = _selectAction(candidates, obligations, confidence, situation);
531
+
532
+ // ── Build rationale
533
+ const rationale = _buildRationale(chosen, situation, confidence, obligations, surfaceNoticings);
534
+
535
+ return {
536
+ depth,
537
+ action: chosen,
538
+ rationale,
539
+ confidence,
540
+ obligations: obligations.filter(o => o.priority === 'critical' || o.priority === 'high'),
541
+ surfaceNoticings,
542
+ shouldAskUser,
543
+ uncertainties: confidence.gaps,
544
+ };
285
545
  }
286
546
 
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;
547
+ function _reflexiveAction(situation) {
548
+ if (situation.isQuestion && situation.taskShape.type === 'answer') {
549
+ return { type: 'respond', mode: 'direct' };
550
+ }
551
+ if (situation.isShort && /^(yes|y|ok|go|do it|sure|approved)\s*$/i.test(situation.raw.trim())) {
552
+ return { type: 'proceed', mode: 'approved' };
553
+ }
554
+ if (situation.isShort && /^(no|stop|wait|hold)\s*$/i.test(situation.raw.trim())) {
555
+ return { type: 'pause', mode: 'correction' };
556
+ }
557
+ return { type: 'respond', mode: 'direct' };
558
+ }
559
+
560
+ function _shouldAsk(situation, confidence, obligations, depth) {
561
+ // Obligation-driven: ask before irreversible
562
+ if (obligations.some(o => o.type === 'askBeforeIrreversi')) return true;
563
+
564
+ // Confidence-driven: ask when insufficient
565
+ if (confidence.blockers?.length > 0) return true;
566
+
567
+ // Relationship-driven: user signals suggest asking
568
+ if (situation.relationship.shouldAsk) return true;
569
+
570
+ // Depth-driven: deep deliberation means this is complex enough to check
571
+ if (depth === 'deep' && confidence.level !== 'sufficient') return true;
572
+
573
+ // Intent mismatch: what user asked might not be what they need
574
+ if (situation.relationship.likelyMismatch) return true;
575
+
576
+ return false;
577
+ }
578
+
579
+ function _generateCandidates(situation, confidence, obligations) {
580
+ const candidates = [];
581
+
582
+ // Direct response (answer/explain)
583
+ if (situation.taskShape.type === 'answer' || situation.taskShape.type === 'research') {
584
+ candidates.push({
585
+ type: 'respond',
586
+ mode: 'direct',
587
+ fitness: situation.isQuestion ? 0.9 : 0.6,
588
+ });
589
+ }
590
+
591
+ // Dispatch to worker agent
592
+ if (['edit', 'debug', 'review'].includes(situation.taskShape.type)) {
593
+ candidates.push({
594
+ type: 'dispatch',
595
+ mode: situation.taskShape.type,
596
+ fitness: confidence.level === 'sufficient' ? 0.85 : 0.4,
597
+ });
598
+ }
599
+
600
+ // Plan first, then dispatch
601
+ if (situation.taskShape.scope !== 'small' || situation.taskShape.ambiguity !== 'low') {
602
+ candidates.push({
603
+ type: 'plan',
604
+ mode: 'structured',
605
+ fitness: situation.taskShape.ambiguity === 'high' ? 0.9 : 0.6,
606
+ });
607
+ }
608
+
609
+ // Clarify with user
610
+ if (confidence.level === 'insufficient' || situation.relationship.wrongAssumption) {
611
+ candidates.push({
612
+ type: 'clarify',
613
+ mode: 'question',
614
+ fitness: confidence.level === 'insufficient' ? 0.95 : 0.7,
615
+ });
616
+ }
617
+
618
+ // Think/discuss (architecture, design)
619
+ if (situation.taskShape.type === 'plan') {
620
+ candidates.push({
621
+ type: 'think',
622
+ mode: 'architecture',
623
+ fitness: 0.85,
624
+ });
625
+ }
626
+
627
+ // Proceed (user gave approval)
628
+ if (situation.isShort && /^(yes|y|ok|go|do it|sure|approved)\s*$/i.test(situation.raw.trim())) {
629
+ candidates.push({
630
+ type: 'proceed',
631
+ mode: 'approved',
632
+ fitness: 0.95,
633
+ });
634
+ }
635
+
636
+ return candidates;
298
637
  }
299
638
 
300
- // ── Decision logging ─────────────────────────────────────────────────────────
639
+ function _selectAction(candidates, obligations, confidence, situation) {
640
+ if (candidates.length === 0) return { type: 'clarify', mode: 'no-candidates' };
641
+
642
+ // Apply obligation penalties
643
+ for (const c of candidates) {
644
+ // Dispatch penalty when confidence is low
645
+ if (c.type === 'dispatch' && confidence.level !== 'sufficient') {
646
+ c.fitness *= 0.5;
647
+ }
648
+
649
+ // Dispatch penalty when irreversible and no approval
650
+ if (c.type === 'dispatch' && obligations.some(o => o.type === 'askBeforeIrreversi')) {
651
+ c.fitness *= 0.3;
652
+ }
653
+
654
+ // Plan bonus when scope is large
655
+ if (c.type === 'plan' && situation.taskShape.scope === 'large') {
656
+ c.fitness *= 1.3;
657
+ }
658
+
659
+ // Clarify bonus when goal is mismatched
660
+ if (c.type === 'clarify' && situation.relationship.likelyMismatch) {
661
+ c.fitness *= 1.5;
662
+ }
663
+ }
664
+
665
+ // Sort by fitness, pick best
666
+ candidates.sort((a, b) => b.fitness - a.fitness);
667
+ return candidates[0];
668
+ }
669
+
670
+ function _buildRationale(action, situation, confidence, obligations, noticings) {
671
+ const parts = [];
672
+
673
+ parts.push(`Action: ${action.type} (${action.mode})`);
674
+
675
+ if (confidence.level !== 'sufficient') {
676
+ parts.push(`Confidence: ${confidence.level} (${confidence.score}) — ${confidence.gaps.length} gap(s)`);
677
+ }
678
+
679
+ const criticalObligations = obligations.filter(o => o.priority === 'critical');
680
+ if (criticalObligations.length > 0) {
681
+ parts.push(`Critical obligations: ${criticalObligations.map(o => o.type).join(', ')}`);
682
+ }
683
+
684
+ if (noticings.length > 0) {
685
+ parts.push(`Surfacing ${noticings.length} noticing(s)`);
686
+ }
687
+
688
+ if (situation.inferredGoal) {
689
+ parts.push(`Note: inferred goal may differ — "${situation.inferredGoal}"`);
690
+ }
691
+
692
+ return parts.join('. ');
693
+ }
694
+
695
+ // ── Full turn processor ─────────────────────────────────────────────────────
301
696
 
302
697
  /**
303
- * Log a HEAD decision.
698
+ * Process a complete turn through the cognitive pipeline.
699
+ * This replaces the old processTurn — same interface, fundamentally different internals.
304
700
  */
305
- export function logDecision(state, decision) {
306
- state.decisions.push({
307
- ...decision,
701
+ export function processTurn(state, userMessage, context = {}) {
702
+ // 1. Perceive the situation
703
+ const situation = perceive(userMessage, context);
704
+
705
+ // 2. Assess depth — how much thinking does this deserve?
706
+ const depth = assessDepth(situation);
707
+
708
+ // 3. Build uncertainty ledger
709
+ const uncertainties = assessUncertainty(situation, context);
710
+
711
+ // 4. Derive care obligations
712
+ const obligations = deriveObligations(situation);
713
+
714
+ // 5. Passive noticing
715
+ const noticings = notice(situation, state, context);
716
+
717
+ // 6. Deliberate
718
+ const result = deliberate(situation, uncertainties, obligations, noticings, state);
719
+
720
+ // Update state
721
+ state.lastActivity = Date.now();
722
+ if (!state.declaredGoal && situation.taskShape.type !== 'answer') {
723
+ state.declaredGoal = situation.explicitAsk.slice(0, 200);
724
+ state.originalScope = situation.material.touchedFiles.length || 1;
725
+ }
726
+
727
+ // Track the turn
728
+ if (!state.turns) state.turns = [];
729
+ state.turns.push({
308
730
  timestamp: Date.now(),
731
+ depth: result.depth,
732
+ action: result.action.type,
733
+ confidence: result.confidence.score,
734
+ obligationCount: result.obligations.length,
735
+ noticingCount: result.surfaceNoticings.length,
309
736
  });
310
- return state;
737
+
738
+ saveState(state);
739
+
740
+ return {
741
+ situation,
742
+ depth,
743
+ uncertainties,
744
+ obligations,
745
+ noticings,
746
+ result,
747
+
748
+ // Convenience fields for callers
749
+ shouldAskUser: result.shouldAskUser,
750
+ shouldDispatch: result.action.type === 'dispatch' || result.action.type === 'proceed',
751
+ shouldClarify: result.action.type === 'clarify',
752
+ shouldThink: result.action.type === 'think' || result.action.type === 'plan',
753
+ action: result.action,
754
+ rationale: result.rationale,
755
+ };
311
756
  }
312
757
 
313
- // ── Convenience: process a user message ──────────────────────────────────────
758
+ // ── State persistence ───────────────────────────────────────────────────────
314
759
 
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),
760
+ export function loadState() {
761
+ try {
762
+ if (existsSync(STATE_FILE)) {
763
+ const data = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
764
+ if (Date.now() - (data.lastActivity || 0) > 30 * 60 * 1000) {
765
+ return freshState();
766
+ }
767
+ return data;
768
+ }
769
+ } catch {}
770
+ return freshState();
771
+ }
772
+
773
+ export function freshState() {
774
+ return {
775
+ sessionId: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
776
+ declaredGoal: null,
777
+ originalScope: null,
778
+ turns: [],
779
+ contextEstimate: { messages: 0, estimatedTokens: 0 },
780
+ lastActivity: Date.now(),
781
+ created: Date.now(),
344
782
  };
783
+ }
345
784
 
346
- saveState(state);
347
- return guidance;
785
+ export function saveState(state) {
786
+ state.lastActivity = Date.now();
787
+ mkdirSync(STATE_DIR, { recursive: true });
788
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
348
789
  }
349
790
 
350
- // ── Exports summary ──────────────────────────────────────────────────────────
351
- // classifyIntent, loadState, saveState, freshState (via loadState),
352
- // transition, suggestPhase, checkConfidence, detectDrift,
353
- // updateContextEstimate, trackTask, completeTask, logDecision, processTurn
791
+ // ── Exports ─────────────────────────────────────────────────────────────────
792
+ // Core pipeline: perceive, assessUncertainty, deriveObligations, notice, deliberate
793
+ // Convenience: processTurn, assessDepth, summarizeConfidence
794
+ // State: loadState, freshState, saveState
795
+ // Values: HEAD_VALUES