dual-brain 7.1.21 → 7.1.23

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