dual-brain 7.1.21 → 7.1.22
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/bin/dual-brain.mjs +2580 -717
- package/hooks/budget-balancer.mjs +104 -266
- package/hooks/wave-orchestrator.mjs +29 -26
- package/package.json +13 -3
- package/scripts/verify-publish.mjs +26 -0
- package/src/context.mjs +389 -0
- package/src/decide.mjs +283 -60
- package/src/detect.mjs +133 -1
- package/src/dispatch.mjs +175 -30
- package/src/doctor.mjs +577 -0
- package/src/failure-memory.mjs +178 -0
- package/src/nextstep.mjs +100 -0
- package/src/observer.mjs +241 -0
- package/src/outcome.mjs +256 -0
- package/src/pipeline.mjs +759 -0
- package/src/profile.mjs +357 -485
- package/src/receipt.mjs +131 -0
- package/src/session.mjs +358 -10
package/src/pipeline.mjs
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pipeline.mjs — Unified Pipeline for dual-brain.
|
|
3
|
+
// Every feature (go, think, review, watch, auto-commit, pr-triage, wave) routes through here.
|
|
4
|
+
// Exports: runPipeline, buildExecutionPlan, formatExecutionPlan, createPipelineRun
|
|
5
|
+
// Gate exports: contextGate, planningGate, principleGate, executionGate, outcomeGate
|
|
6
|
+
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { detectTask } from './detect.mjs';
|
|
10
|
+
import { decideRoute, getWorkStyle, WORK_STYLES } from './decide.mjs';
|
|
11
|
+
import { dispatch } from './dispatch.mjs';
|
|
12
|
+
import { loadProfile } from './profile.mjs';
|
|
13
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
// ─── PipelineRun factory ──────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a fresh PipelineRun object.
|
|
20
|
+
* @param {string} trigger
|
|
21
|
+
* @param {string} prompt
|
|
22
|
+
* @returns {object}
|
|
23
|
+
*/
|
|
24
|
+
export function createPipelineRun(trigger = '', prompt = '') {
|
|
25
|
+
return {
|
|
26
|
+
id: randomUUID(),
|
|
27
|
+
startedAt: Date.now(),
|
|
28
|
+
trigger,
|
|
29
|
+
prompt,
|
|
30
|
+
|
|
31
|
+
// Phase 1: Context
|
|
32
|
+
context: null,
|
|
33
|
+
failureHistory: null, // result of checkFailureHistory — even empty counts as "queried"
|
|
34
|
+
priorOutcomes: null, // result of getRelevantOutcomes — even empty counts as "queried"
|
|
35
|
+
|
|
36
|
+
// Gate results
|
|
37
|
+
gates: {
|
|
38
|
+
context: null, // { passed: bool, reason: string }
|
|
39
|
+
planning: null,
|
|
40
|
+
principle: null,
|
|
41
|
+
execution: null,
|
|
42
|
+
outcome: null,
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// Phase 2: Plan
|
|
46
|
+
plan: null,
|
|
47
|
+
|
|
48
|
+
// Phase 3: Execution
|
|
49
|
+
result: null,
|
|
50
|
+
|
|
51
|
+
// Phase 4: Verification
|
|
52
|
+
verification: null,
|
|
53
|
+
|
|
54
|
+
// Phase 5: Outcome
|
|
55
|
+
outcome: null,
|
|
56
|
+
|
|
57
|
+
completedAt: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Gate helpers ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function gate(passed, reason) {
|
|
64
|
+
return { passed: Boolean(passed), reason: reason ?? '' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Principle predicates ─────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Block if 2 or more prior failures on the same approach.
|
|
71
|
+
*/
|
|
72
|
+
function rejectsRepeatedFailedApproach(run) {
|
|
73
|
+
const count = run.failureHistory?.failureCount ?? 0;
|
|
74
|
+
if (count >= 2) {
|
|
75
|
+
return { blocked: true, reason: `${count} prior failures on similar approach — must change strategy or use dual-brain` };
|
|
76
|
+
}
|
|
77
|
+
return { blocked: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Block if no plan is present.
|
|
82
|
+
*/
|
|
83
|
+
function requiresApprovedPlan(run) {
|
|
84
|
+
if (!run.plan) {
|
|
85
|
+
return { blocked: true, reason: 'No execution plan — pipeline cannot proceed without a plan' };
|
|
86
|
+
}
|
|
87
|
+
return { blocked: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Warn if plan touches more than 10 files or 3+ unrelated areas.
|
|
92
|
+
* Not a hard block — returns warning in reason but blocked: false.
|
|
93
|
+
*/
|
|
94
|
+
function rejectsScopeCreep(run) {
|
|
95
|
+
const fileCount = run.context?.files?.explicit?.length ?? 0;
|
|
96
|
+
const extractedCount = run.context?.files?.extracted?.length ?? 0;
|
|
97
|
+
const total = fileCount + extractedCount;
|
|
98
|
+
|
|
99
|
+
if (total > 10) {
|
|
100
|
+
return { blocked: false, reason: `Scope warning: plan touches ${total} files — consider splitting into smaller tasks` };
|
|
101
|
+
}
|
|
102
|
+
return { blocked: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Block high/critical risk tasks that have no challenger configured.
|
|
107
|
+
*/
|
|
108
|
+
function requiresDualBrainForHighRisk(run) {
|
|
109
|
+
const risk = run.context?.detection?.risk ?? 'low';
|
|
110
|
+
const hasChallenger = run.plan?.useChallenger && run.plan?.challengerModel;
|
|
111
|
+
|
|
112
|
+
if ((risk === 'high' || risk === 'critical') && !hasChallenger) {
|
|
113
|
+
return { blocked: true, reason: `High-risk task (${risk}) requires dual-brain challenger — configure OpenAI provider or lower risk scope` };
|
|
114
|
+
}
|
|
115
|
+
return { blocked: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Five mandatory gates ─────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Gate 1: Context gate.
|
|
122
|
+
* Passes only if failureHistory and priorOutcomes were actually queried (not null).
|
|
123
|
+
*/
|
|
124
|
+
export function contextGate(run) {
|
|
125
|
+
if (run.failureHistory === null) {
|
|
126
|
+
return gate(false, 'failureHistory was never queried — context phase incomplete');
|
|
127
|
+
}
|
|
128
|
+
if (run.priorOutcomes === null) {
|
|
129
|
+
return gate(false, 'priorOutcomes was never queried — context phase incomplete');
|
|
130
|
+
}
|
|
131
|
+
if (run.context === null) {
|
|
132
|
+
return gate(false, 'context pack was never built — context phase incomplete');
|
|
133
|
+
}
|
|
134
|
+
return gate(true, 'context loaded');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Gate 2: Planning gate.
|
|
139
|
+
* Passes if plan exists AND the proposed approach doesn't repeat a known failure.
|
|
140
|
+
*/
|
|
141
|
+
export function planningGate(run) {
|
|
142
|
+
if (!run.plan) {
|
|
143
|
+
return gate(false, 'No execution plan built');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check if the approach matches a prior failure
|
|
147
|
+
const history = run.failureHistory;
|
|
148
|
+
if (history?.hasPriorFailures && history?.escalation?.recommended) {
|
|
149
|
+
const esc = history.escalation;
|
|
150
|
+
// If the plan doesn't reflect the escalation (still using low depth when ultra is recommended)
|
|
151
|
+
const planDepth = run.plan.reasoningDepth ?? 'low';
|
|
152
|
+
const needsDepth = esc.toDepth ?? 'low';
|
|
153
|
+
const depthOrder = ['low', 'medium', 'high', 'ultra'];
|
|
154
|
+
const planIdx = depthOrder.indexOf(planDepth);
|
|
155
|
+
const needsIdx = depthOrder.indexOf(needsDepth);
|
|
156
|
+
|
|
157
|
+
if (planIdx < needsIdx) {
|
|
158
|
+
return gate(
|
|
159
|
+
false,
|
|
160
|
+
`Plan uses ${planDepth} reasoning but prior failures require ${needsDepth}. ${esc.reason}. Use a different strategy.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return gate(true, 'plan approved');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Gate 3: Principle gate.
|
|
170
|
+
* Runs all principle predicates — any hard block fails the gate.
|
|
171
|
+
*/
|
|
172
|
+
export function principleGate(run) {
|
|
173
|
+
const checks = [
|
|
174
|
+
rejectsRepeatedFailedApproach(run),
|
|
175
|
+
requiresApprovedPlan(run),
|
|
176
|
+
rejectsScopeCreep(run),
|
|
177
|
+
requiresDualBrainForHighRisk(run),
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const blocked = checks.find(c => c.blocked);
|
|
181
|
+
if (blocked) {
|
|
182
|
+
return gate(false, blocked.reason);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Collect non-blocking warnings for the reason field
|
|
186
|
+
const warnings = checks.filter(c => !c.blocked && c.reason).map(c => c.reason);
|
|
187
|
+
return gate(true, warnings.length ? warnings.join('; ') : 'all principles satisfied');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Gate 4: Execution gate.
|
|
192
|
+
* Final "cleared to work?" check — all previous gates must have passed and plan must exist.
|
|
193
|
+
*/
|
|
194
|
+
export function executionGate(run) {
|
|
195
|
+
const prevGates = ['context', 'planning', 'principle'];
|
|
196
|
+
for (const name of prevGates) {
|
|
197
|
+
const g = run.gates[name];
|
|
198
|
+
if (!g || !g.passed) {
|
|
199
|
+
return gate(false, `Upstream gate '${name}' did not pass — cannot proceed to execution`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!run.plan) {
|
|
203
|
+
return gate(false, 'No plan present at execution gate');
|
|
204
|
+
}
|
|
205
|
+
return gate(true, 'cleared for execution');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Gate 5: Outcome gate.
|
|
210
|
+
* After execution, checks that an outcome was recorded.
|
|
211
|
+
*/
|
|
212
|
+
export function outcomeGate(run) {
|
|
213
|
+
if (run.result && run.outcome === null) {
|
|
214
|
+
return gate(false, 'Execution completed but outcome was not recorded');
|
|
215
|
+
}
|
|
216
|
+
return gate(true, 'outcome recorded');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Context Pack ─────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build a context pack from the raw inputs.
|
|
223
|
+
* @param {string} prompt
|
|
224
|
+
* @param {string[]} files
|
|
225
|
+
* @param {string} cwd
|
|
226
|
+
* @returns {object}
|
|
227
|
+
*/
|
|
228
|
+
async function buildContextPack(prompt, files = [], cwd = process.cwd()) {
|
|
229
|
+
const profile = await _loadProfileSafe(cwd);
|
|
230
|
+
|
|
231
|
+
const priorFailures = _getPriorFailures(prompt, cwd);
|
|
232
|
+
|
|
233
|
+
const detection = detectTask({ prompt, files, priorFailures });
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
prompt,
|
|
237
|
+
files: { explicit: files, extracted: detection.specialist?.triggers ?? [] },
|
|
238
|
+
detection,
|
|
239
|
+
profile,
|
|
240
|
+
priorFailures,
|
|
241
|
+
cwd,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Reasoning depth ──────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const UNCERTAINTY_WORDS = /\b(not sure|maybe|should we|perhaps|architect|design|unsure|consider|what if|would it be|thinking about)\b/i;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Classify reasoning depth from context pack signals.
|
|
251
|
+
* @param {object} contextPack
|
|
252
|
+
* @returns {'low'|'medium'|'high'|'ultra'}
|
|
253
|
+
*/
|
|
254
|
+
export function classifyReasoningDepth(contextPack) {
|
|
255
|
+
const { detection, files, priorFailures = 0, prompt = '' } = contextPack;
|
|
256
|
+
const { risk = 'low', tier } = detection;
|
|
257
|
+
const fileCount = files.explicit.length;
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
risk === 'critical' ||
|
|
261
|
+
tier === 'think' ||
|
|
262
|
+
priorFailures >= 2 ||
|
|
263
|
+
UNCERTAINTY_WORDS.test(prompt)
|
|
264
|
+
) return 'ultra';
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
risk === 'high' ||
|
|
268
|
+
fileCount > 5 ||
|
|
269
|
+
detection.complexity === 'complex'
|
|
270
|
+
) return 'high';
|
|
271
|
+
|
|
272
|
+
if (
|
|
273
|
+
risk === 'medium' ||
|
|
274
|
+
(fileCount >= 3 && fileCount <= 5) ||
|
|
275
|
+
detection.complexity === 'moderate'
|
|
276
|
+
) return 'medium';
|
|
277
|
+
|
|
278
|
+
return 'low';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Challenger policy ────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
const THINK_TRIGGERS = new Set(['think', 'review']);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Determine whether challenger activates based on work style and risk.
|
|
287
|
+
* @param {object} contextPack
|
|
288
|
+
* @param {string} trigger
|
|
289
|
+
* @returns {boolean}
|
|
290
|
+
*/
|
|
291
|
+
function shouldUseChallenger(contextPack, trigger) {
|
|
292
|
+
const { detection, profile, priorFailures = 0 } = contextPack;
|
|
293
|
+
const { risk = 'low' } = detection;
|
|
294
|
+
|
|
295
|
+
// Always challenger for think/review triggers with prior failures or design impact
|
|
296
|
+
if (priorFailures >= 2 || detection.designImpact || THINK_TRIGGERS.has(trigger)) return true;
|
|
297
|
+
|
|
298
|
+
const style = getWorkStyle(profile);
|
|
299
|
+
|
|
300
|
+
if (style.challengerPolicy === 'never') return false;
|
|
301
|
+
if (style.challengerPolicy === 'high-risk') return risk === 'high' || risk === 'critical';
|
|
302
|
+
if (style.challengerPolicy === 'medium-risk') return risk !== 'low';
|
|
303
|
+
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Determine whether a checkpoint is required based on work style and risk.
|
|
309
|
+
* @param {object} contextPack
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
function shouldCreateCheckpoint(contextPack) {
|
|
313
|
+
const { detection, profile } = contextPack;
|
|
314
|
+
const { risk = 'low', tier = 'execute' } = detection;
|
|
315
|
+
|
|
316
|
+
const style = getWorkStyle(profile);
|
|
317
|
+
|
|
318
|
+
if (style.checkpointPolicy === 'never') return false;
|
|
319
|
+
if (style.checkpointPolicy === 'all-edits') return tier !== 'search';
|
|
320
|
+
if (style.checkpointPolicy === 'risky-ops') return risk === 'high' || risk === 'critical';
|
|
321
|
+
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Challenger model resolver ────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
function resolveChallenger(useChallenger, contextPack) {
|
|
328
|
+
if (!useChallenger) return null;
|
|
329
|
+
const openaiEnabled =
|
|
330
|
+
contextPack.profile?.providers?.openai?.enabled &&
|
|
331
|
+
contextPack.profile?.providers?.openai?.plan;
|
|
332
|
+
if (!openaiEnabled) return null;
|
|
333
|
+
|
|
334
|
+
const plan = contextPack.profile.providers.openai.plan;
|
|
335
|
+
// Pick the best available OpenAI model for the challenger role
|
|
336
|
+
if (plan === '$100' || plan === '$200') return 'o3'; // doctor:verified — config value comparison, not UI display
|
|
337
|
+
return 'gpt-4o';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Build execution plan ─────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Build an execution plan from context pack + trigger + options.
|
|
344
|
+
* @param {object} contextPack
|
|
345
|
+
* @param {string} trigger
|
|
346
|
+
* @param {object} options
|
|
347
|
+
* @returns {object}
|
|
348
|
+
*/
|
|
349
|
+
export function buildExecutionPlan(contextPack, trigger, options = {}) {
|
|
350
|
+
const { detection, profile, priorFailures = 0 } = contextPack;
|
|
351
|
+
|
|
352
|
+
const reasoningDepth = options.forceDepth ?? classifyReasoningDepth(contextPack);
|
|
353
|
+
|
|
354
|
+
const useChallenger = options.forceChallenger || shouldUseChallenger(contextPack, trigger);
|
|
355
|
+
const challengerModel = resolveChallenger(useChallenger, contextPack);
|
|
356
|
+
|
|
357
|
+
const checkpointRequired = shouldCreateCheckpoint(contextPack);
|
|
358
|
+
|
|
359
|
+
// Work style for display and routing context
|
|
360
|
+
const workStyleObj = getWorkStyle(profile);
|
|
361
|
+
const workStyle = workStyleObj.key;
|
|
362
|
+
|
|
363
|
+
// Map reasoning depth → effort hint for decideRoute
|
|
364
|
+
const depthToEffort = { low: 'low', medium: 'medium', high: 'high', ultra: 'xhigh' };
|
|
365
|
+
const detectionWithDepth = {
|
|
366
|
+
...detection,
|
|
367
|
+
effort: depthToEffort[reasoningDepth] ?? detection.effort,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const decision = decideRoute({ profile, detection: detectionWithDepth, cwd: contextPack.cwd });
|
|
371
|
+
|
|
372
|
+
// Resolve full model ID for display (mirrors dispatch.mjs CLAUDE_MODEL_IDS)
|
|
373
|
+
const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
|
|
374
|
+
const displayModel = decision.provider === 'claude'
|
|
375
|
+
? (CLAUDE_MODEL_IDS[decision.model] ?? decision.model)
|
|
376
|
+
: decision.model;
|
|
377
|
+
|
|
378
|
+
const verificationRequired = detection.tier !== 'search';
|
|
379
|
+
|
|
380
|
+
const approvalRequired = detection.risk === 'critical';
|
|
381
|
+
|
|
382
|
+
const explanation = _buildPlanExplanation({
|
|
383
|
+
displayModel,
|
|
384
|
+
reasoningDepth,
|
|
385
|
+
useChallenger,
|
|
386
|
+
workStyle,
|
|
387
|
+
workStyleObj,
|
|
388
|
+
decision,
|
|
389
|
+
detection,
|
|
390
|
+
priorFailures,
|
|
391
|
+
trigger,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
primaryModel: displayModel,
|
|
396
|
+
primaryProvider: decision.provider,
|
|
397
|
+
reasoningDepth,
|
|
398
|
+
useChallenger,
|
|
399
|
+
challengerModel,
|
|
400
|
+
workStyle,
|
|
401
|
+
checkpointRequired,
|
|
402
|
+
tier: detection.tier,
|
|
403
|
+
verificationRequired,
|
|
404
|
+
approvalRequired,
|
|
405
|
+
explanation,
|
|
406
|
+
_decision: decision,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function _buildPlanExplanation({ displayModel, reasoningDepth, useChallenger, workStyle, workStyleObj, decision, detection, priorFailures, trigger }) {
|
|
411
|
+
const parts = [];
|
|
412
|
+
|
|
413
|
+
const modelShort = displayModel.split('/').pop();
|
|
414
|
+
parts.push(`${modelShort} for ${detection.risk}-risk ${detection.intent}`);
|
|
415
|
+
|
|
416
|
+
const styleLabel = workStyleObj?.label ?? workStyle ?? 'balanced';
|
|
417
|
+
parts.push(`style: ${styleLabel}`);
|
|
418
|
+
|
|
419
|
+
if (useChallenger) {
|
|
420
|
+
parts.push('challenger active');
|
|
421
|
+
} else {
|
|
422
|
+
parts.push('no challenger needed');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (priorFailures > 0) {
|
|
426
|
+
parts.push(`${priorFailures} prior failure${priorFailures > 1 ? 's' : ''}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return parts.join(', ');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── Format execution plan ────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Return a human-readable display string for an execution plan.
|
|
436
|
+
* @param {object} plan
|
|
437
|
+
* @returns {string}
|
|
438
|
+
*/
|
|
439
|
+
export function formatExecutionPlan(plan) {
|
|
440
|
+
const depthLabel = { low: 'low reasoning', medium: 'medium reasoning', high: 'high reasoning', ultra: 'ultra reasoning' };
|
|
441
|
+
|
|
442
|
+
// Work style label + challenger description
|
|
443
|
+
const styleKey = plan.workStyle ?? 'balanced';
|
|
444
|
+
const styleDef = WORK_STYLES[styleKey] ?? WORK_STYLES.balanced;
|
|
445
|
+
const challengerNote = plan.useChallenger
|
|
446
|
+
? `challenger on${plan.challengerModel ? ` (${plan.challengerModel})` : ''}`
|
|
447
|
+
: `challenger off (policy: ${styleDef.challengerPolicy})`;
|
|
448
|
+
|
|
449
|
+
const lines = [
|
|
450
|
+
'⚡ Execution Plan',
|
|
451
|
+
` Model: ${plan.primaryModel} (${depthLabel[plan.reasoningDepth] ?? plan.reasoningDepth})`,
|
|
452
|
+
` Mode: ${styleDef.label} — ${challengerNote}`,
|
|
453
|
+
` Checkpoint: ${plan.checkpointRequired ? 'yes (risky operation detected)' : 'no'}`,
|
|
454
|
+
` Risk: ${plan._decision?.risk ?? 'unknown'} | Tier: ${plan.tier}`,
|
|
455
|
+
` Verify: ${plan.verificationRequired ? 'yes' : 'no'} | Approval: ${plan.approvalRequired ? 'yes' : 'no'}`,
|
|
456
|
+
` Why: ${plan.explanation}`,
|
|
457
|
+
];
|
|
458
|
+
return lines.join('\n');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Checkpoint ───────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Create a lightweight safety checkpoint before a risky operation.
|
|
465
|
+
* Tries git stash create first (non-destructive ref), falls back to recording HEAD.
|
|
466
|
+
* Always best-effort — never throws.
|
|
467
|
+
* @param {string} cwd
|
|
468
|
+
* @param {object} contextPack
|
|
469
|
+
*/
|
|
470
|
+
async function createCheckpoint(cwd, contextPack) {
|
|
471
|
+
try {
|
|
472
|
+
const checkpointDir = join(cwd, '.dualbrain', 'checkpoints');
|
|
473
|
+
mkdirSync(checkpointDir, { recursive: true });
|
|
474
|
+
|
|
475
|
+
let ref = null;
|
|
476
|
+
|
|
477
|
+
// Try git stash create (creates a stash object without modifying working tree)
|
|
478
|
+
try {
|
|
479
|
+
const stashRef = execSync('git stash create', { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
480
|
+
.toString().trim();
|
|
481
|
+
if (stashRef) ref = stashRef;
|
|
482
|
+
} catch {
|
|
483
|
+
// git stash create failed or no changes — fall through
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Fallback: record current HEAD
|
|
487
|
+
if (!ref) {
|
|
488
|
+
try {
|
|
489
|
+
ref = execSync('git rev-parse HEAD', { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
490
|
+
.toString().trim();
|
|
491
|
+
} catch {
|
|
492
|
+
ref = 'unknown';
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
497
|
+
const entry = {
|
|
498
|
+
timestamp: new Date().toISOString(),
|
|
499
|
+
ref,
|
|
500
|
+
prompt: contextPack.prompt?.slice(0, 120),
|
|
501
|
+
risk: contextPack.detection?.risk,
|
|
502
|
+
tier: contextPack.detection?.tier,
|
|
503
|
+
};
|
|
504
|
+
writeFileSync(join(checkpointDir, `${ts}.json`), JSON.stringify(entry, null, 2));
|
|
505
|
+
} catch {
|
|
506
|
+
// Checkpoint is best-effort — never block execution
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Verification ─────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Verify the dispatch result meets basic expectations.
|
|
514
|
+
* @param {object} result Result from dispatch()
|
|
515
|
+
* @param {object} plan Execution plan
|
|
516
|
+
* @param {string} cwd
|
|
517
|
+
* @returns {{ ok: boolean, notes: string[] }}
|
|
518
|
+
*/
|
|
519
|
+
async function verify(result, plan, cwd) {
|
|
520
|
+
const notes = [];
|
|
521
|
+
|
|
522
|
+
if (!result || result.status === 'error' || result.status === 'failed') {
|
|
523
|
+
return { ok: false, notes: ['Dispatch returned failure status'] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (plan.tier !== 'search') {
|
|
527
|
+
try {
|
|
528
|
+
const gitOut = execSync('git status --porcelain', { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
529
|
+
if (gitOut.trim()) {
|
|
530
|
+
notes.push(`Files changed (git status shows ${gitOut.trim().split('\n').length} modified)`);
|
|
531
|
+
} else {
|
|
532
|
+
notes.push('No file changes detected by git — verify task actually ran');
|
|
533
|
+
}
|
|
534
|
+
} catch {
|
|
535
|
+
// git not available or not a repo — skip
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return { ok: true, notes };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─── Outcome recording ────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
async function recordOutcomeSafe(run) {
|
|
545
|
+
try {
|
|
546
|
+
const { recordOutcome } = await import('./outcome.mjs');
|
|
547
|
+
const cwd = run.context?.cwd ?? process.cwd();
|
|
548
|
+
const recorded = await recordOutcome(run.plan, run.result, run.verification, cwd);
|
|
549
|
+
run.outcome = recorded;
|
|
550
|
+
} catch {
|
|
551
|
+
// outcome.mjs doesn't exist yet — silently skip
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── Prior failures ───────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
// In-process cache of prior failures keyed by a rough prompt fingerprint.
|
|
558
|
+
// Populated by recordOutcomeSafe when outcome.mjs is available; otherwise 0.
|
|
559
|
+
const _priorFailureCache = new Map();
|
|
560
|
+
|
|
561
|
+
function _getPriorFailures(prompt, _cwd) {
|
|
562
|
+
const key = prompt.slice(0, 40).toLowerCase().replace(/\s+/g, ' ');
|
|
563
|
+
return _priorFailureCache.get(key) ?? 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function _incrementFailureCache(prompt) {
|
|
567
|
+
const key = prompt.slice(0, 40).toLowerCase().replace(/\s+/g, ' ');
|
|
568
|
+
_priorFailureCache.set(key, (_priorFailureCache.get(key) ?? 0) + 1);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ─── Profile loader (safe) ────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
async function _loadProfileSafe(cwd) {
|
|
574
|
+
try {
|
|
575
|
+
return await loadProfile(cwd);
|
|
576
|
+
} catch {
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ─── Gate runner ─────────────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Run a named gate, store its result in run.gates, and return whether it passed.
|
|
585
|
+
* If gate throws, it is treated as a failure (fail-closed).
|
|
586
|
+
*/
|
|
587
|
+
function runGate(run, gateName, gateFn) {
|
|
588
|
+
let result;
|
|
589
|
+
try {
|
|
590
|
+
result = gateFn(run);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
result = gate(false, `Gate '${gateName}' threw: ${err.message}`);
|
|
593
|
+
}
|
|
594
|
+
// Treat missing result or missing passed field as fail-closed
|
|
595
|
+
if (!result || typeof result.passed !== 'boolean') {
|
|
596
|
+
result = gate(false, `Gate '${gateName}' returned invalid result`);
|
|
597
|
+
}
|
|
598
|
+
run.gates[gateName] = result;
|
|
599
|
+
return result.passed;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ─── Main entry point ─────────────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Run the unified pipeline.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} trigger What invoked the pipeline: 'go'|'think'|'review'|'watch'|'auto-commit'|'pr-triage'|'wave'
|
|
608
|
+
* @param {string} prompt The user's task description
|
|
609
|
+
* @param {object} options
|
|
610
|
+
* @param {string[]} [options.files] Explicit file paths
|
|
611
|
+
* @param {string} [options.cwd] Working directory
|
|
612
|
+
* @param {boolean} [options.dryRun] Show plan without executing
|
|
613
|
+
* @param {boolean} [options.verbose] Show routing details
|
|
614
|
+
* @param {string} [options.forceDepth] Override reasoning depth
|
|
615
|
+
* @param {boolean} [options.forceChallenger] Force dual-brain challenger
|
|
616
|
+
* @param {boolean} [options.silent] Suppress all output
|
|
617
|
+
* @returns {Promise<{ plan: object, result: object|null, verification: object|null } | { success: false, gateFailure: string, reason: string, run: object } | { success: true, run: object }>}
|
|
618
|
+
*/
|
|
619
|
+
export async function runPipeline(trigger, prompt, options = {}) {
|
|
620
|
+
const {
|
|
621
|
+
files = [],
|
|
622
|
+
cwd = process.cwd(),
|
|
623
|
+
dryRun = false,
|
|
624
|
+
verbose = false,
|
|
625
|
+
forceDepth,
|
|
626
|
+
forceChallenger = false,
|
|
627
|
+
silent = false,
|
|
628
|
+
} = options;
|
|
629
|
+
|
|
630
|
+
const log = silent ? () => {} : (msg) => process.stderr.write(msg + '\n');
|
|
631
|
+
|
|
632
|
+
// Create the PipelineRun state object
|
|
633
|
+
const run = createPipelineRun(trigger, prompt);
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
// ── Phase 1: Context ──────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
// Build context pack
|
|
639
|
+
run.context = await buildContextPack(prompt, files, cwd);
|
|
640
|
+
|
|
641
|
+
// Query failure history (must happen before context gate)
|
|
642
|
+
try {
|
|
643
|
+
const { checkFailureHistory } = await import('./failure-memory.mjs');
|
|
644
|
+
run.failureHistory = await checkFailureHistory(prompt, files, cwd);
|
|
645
|
+
} catch {
|
|
646
|
+
// failure-memory.mjs unavailable — set to empty result so gate still passes
|
|
647
|
+
run.failureHistory = { hasPriorFailures: false, failureCount: 0, lastFailure: null, escalation: { recommended: false } };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Query relevant outcomes (must happen before context gate)
|
|
651
|
+
try {
|
|
652
|
+
const { getRelevantOutcomes } = await import('./outcome.mjs');
|
|
653
|
+
run.priorOutcomes = await getRelevantOutcomes(prompt, files, cwd);
|
|
654
|
+
} catch {
|
|
655
|
+
// outcome.mjs unavailable — set to empty array so gate still passes
|
|
656
|
+
run.priorOutcomes = [];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Gate 1: Context gate
|
|
660
|
+
if (!runGate(run, 'context', contextGate)) {
|
|
661
|
+
run.completedAt = Date.now();
|
|
662
|
+
return { success: false, gateFailure: 'context', reason: run.gates.context.reason, run };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── Phase 2: Plan ─────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
run.plan = buildExecutionPlan(run.context, trigger, { forceDepth, forceChallenger });
|
|
668
|
+
|
|
669
|
+
if (verbose || dryRun) {
|
|
670
|
+
log(formatExecutionPlan(run.plan));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Gate 2: Planning gate
|
|
674
|
+
if (!runGate(run, 'planning', planningGate)) {
|
|
675
|
+
run.completedAt = Date.now();
|
|
676
|
+
return { success: false, gateFailure: 'planning', reason: run.gates.planning.reason, run };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Gate 3: Principle gate
|
|
680
|
+
if (!runGate(run, 'principle', principleGate)) {
|
|
681
|
+
run.completedAt = Date.now();
|
|
682
|
+
return { success: false, gateFailure: 'principle', reason: run.gates.principle.reason, run };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (dryRun) {
|
|
686
|
+
run.completedAt = Date.now();
|
|
687
|
+
// Return legacy-compatible shape for dry-run callers
|
|
688
|
+
return { plan: run.plan, result: null, verification: null, run };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Gate 4: Execution gate (cleared to work?)
|
|
692
|
+
if (!runGate(run, 'execution', executionGate)) {
|
|
693
|
+
run.completedAt = Date.now();
|
|
694
|
+
return { success: false, gateFailure: 'execution', reason: run.gates.execution.reason, run };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Phase 3: Execute ──────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
// Checkpoint (best-effort, before execute)
|
|
700
|
+
if (run.plan.checkpointRequired) {
|
|
701
|
+
await createCheckpoint(cwd, run.context);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const decision = { ...run.plan._decision };
|
|
705
|
+
|
|
706
|
+
run.result = await dispatch({
|
|
707
|
+
decision,
|
|
708
|
+
prompt,
|
|
709
|
+
files,
|
|
710
|
+
cwd,
|
|
711
|
+
dryRun: false,
|
|
712
|
+
verbose,
|
|
713
|
+
profile: run.context.profile,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// ── Phase 4: Verification ─────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
run.verification = await verify(run.result, run.plan, cwd);
|
|
719
|
+
|
|
720
|
+
if (verbose) {
|
|
721
|
+
log(`[pipeline] verification: ${run.verification.ok ? 'ok' : 'failed'}`);
|
|
722
|
+
for (const note of run.verification.notes) log(`[pipeline] ${note}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (!run.verification.ok) {
|
|
726
|
+
_incrementFailureCache(prompt);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ── Phase 5: Outcome ──────────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
await recordOutcomeSafe(run);
|
|
732
|
+
|
|
733
|
+
// Gate 5: Outcome gate
|
|
734
|
+
if (!runGate(run, 'outcome', outcomeGate)) {
|
|
735
|
+
run.completedAt = Date.now();
|
|
736
|
+
return { success: false, gateFailure: 'outcome', reason: run.gates.outcome.reason, run };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
} catch (err) {
|
|
740
|
+
log(`[pipeline] error in pipeline step: ${err.message}`);
|
|
741
|
+
run.result = { status: 'error', error: err.message };
|
|
742
|
+
run.verification = { ok: false, notes: [err.message] };
|
|
743
|
+
if (run.context) _incrementFailureCache(prompt);
|
|
744
|
+
run.completedAt = Date.now();
|
|
745
|
+
return { success: false, gateFailure: 'error', reason: err.message, run };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
run.completedAt = Date.now();
|
|
749
|
+
|
|
750
|
+
// Return both new-style and legacy-compatible shapes
|
|
751
|
+
return {
|
|
752
|
+
success: true,
|
|
753
|
+
run,
|
|
754
|
+
// Legacy compatibility
|
|
755
|
+
plan: run.plan,
|
|
756
|
+
result: run.result,
|
|
757
|
+
verification: run.verification,
|
|
758
|
+
};
|
|
759
|
+
}
|