dual-brain 0.1.21 → 0.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.
@@ -909,7 +909,7 @@ function cmdBreakGlass(reason) {
909
909
  // ─── Screen helpers ───────────────────────────────────────────────────────────
910
910
 
911
911
  /**
912
- * Render the data-tools-style rounded header box for the main screen.
912
+ * Render the dual-brain-style rounded header box for the main screen.
913
913
  * Inner width is 39 chars. Lines are padded with spaces to fill the box.
914
914
  */
915
915
  function renderHeader(version, providerLines, dtVersion) {
@@ -924,11 +924,11 @@ function renderHeader(version, providerLines, dtVersion) {
924
924
  const bottom = ` └${'─'.repeat(W)}┘`;
925
925
 
926
926
  const title = `🧠 Dual Brain v${version}`;
927
- const credit = `by Steve Moraco + dual-brain`;
927
+ const credit = `dual-brain`;
928
928
 
929
929
  const allProviderLines = [...providerLines];
930
930
  if (dtVersion) {
931
- allProviderLines.push(`📦 data-tools v${dtVersion} detected`);
931
+ allProviderLines.push(`📦 replit-tools v${dtVersion} detected`);
932
932
  }
933
933
 
934
934
  const lines = [top];
@@ -1711,7 +1711,7 @@ async function mainScreen(rl, ask) {
1711
1711
 
1712
1712
  const statusRows = [row(providerLine)];
1713
1713
  if (dtVersion) {
1714
- statusRows.push(row(`\x1b[2m📦 data-tools v${dtVersion}\x1b[0m`));
1714
+ statusRows.push(row(`\x1b[2m📦 replit-tools v${dtVersion}\x1b[0m`));
1715
1715
  }
1716
1716
 
1717
1717
  // ── Observer observations (top 2, high priority first) ───────────────────
@@ -1837,13 +1837,13 @@ async function mainScreen(rl, ask) {
1837
1837
  });
1838
1838
  }
1839
1839
 
1840
- // ── Actions bar — four product verbs first, then navigation ────────────────
1841
- const actionsContent = 'd Do p Plan r Review s Ship │ n New / Search q Quit';
1840
+ // ── Actions bar — navigation only (pipeline verbs are internal stages, not menu items) ─
1841
+ const actionsContent = 'n New session / Search q Quit';
1842
1842
  const actionsRow = row(actionsContent);
1843
1843
 
1844
1844
  // ── Print the full box ────────────────────────────────────────────────────
1845
1845
  // Include action cards between status and sessions (with separators only when non-empty)
1846
- const poweredByRow = row('\x1b[2mPowered by data-tools · Steve Moraco\x1b[0m');
1846
+ const poweredByRow = row('\x1b[2mPowered by dual-brain\x1b[0m');
1847
1847
  const lines = [
1848
1848
  top,
1849
1849
  ...statusRows,
@@ -1948,7 +1948,7 @@ async function mainScreen(rl, ask) {
1948
1948
  // Single-key commands only fire when buffer is empty
1949
1949
  if (taskBuffer.length === 0) {
1950
1950
  const lower = str.toLowerCase();
1951
- const singleKeySet = new Set(['n', 's', 'q', '/', 'i', 'd', 'p', 'r']);
1951
+ const singleKeySet = new Set(['n', 's', 'q', '/', 'i']);
1952
1952
  if (singleKeySet.has(lower)) {
1953
1953
  cleanup();
1954
1954
  process.stdout.write('\n');
@@ -2017,37 +2017,6 @@ async function mainScreen(rl, ask) {
2017
2017
 
2018
2018
  if (choice === 'n') { return { next: 'new-session' }; }
2019
2019
 
2020
- // Four product verbs
2021
- if (choice === 'd') {
2022
- // "Do" — prompt user for a task description, then dispatch
2023
- const prompt = (await ask(' What do you want to do? ')).trim();
2024
- if (!prompt) return { next: 'main' };
2025
- return { next: 'go', prompt };
2026
- }
2027
-
2028
- if (choice === 'p') {
2029
- // "Plan" — dry-run routing for a task
2030
- const prompt = (await ask(' Describe the task to plan: ')).trim();
2031
- if (!prompt) return { next: 'main' };
2032
- return { next: 'go', prompt, dryRun: true };
2033
- }
2034
-
2035
- if (choice === 'r') {
2036
- // "Review" — dual-brain review current diff
2037
- const { spawnSync } = await import('node:child_process');
2038
- process.stdout.write('\n Running dual-brain review...\n\n');
2039
- spawnSync('node', ['.claude/hooks/dual-brain-review.mjs'], { stdio: 'inherit', cwd });
2040
- return { next: 'main' };
2041
- }
2042
-
2043
- if (choice === 's') {
2044
- // "Ship" — run quality gate then prompt for commit/PR
2045
- const { spawnSync } = await import('node:child_process');
2046
- process.stdout.write('\n Running quality gate + ship flow...\n\n');
2047
- spawnSync('node', ['.claude/hooks/quality-gate.mjs'], { stdio: 'inherit', cwd });
2048
- return { next: 'main' };
2049
- }
2050
-
2051
2020
  if (choice === '/') {
2052
2021
  const query = (await ask(' Search: ')).trim();
2053
2022
  if (!query) return { next: 'main' };
@@ -2084,6 +2053,7 @@ async function mainScreen(rl, ask) {
2084
2053
  return { next: 'main' };
2085
2054
  }
2086
2055
 
2056
+ if (choice === 's') { return { next: 'settings' }; }
2087
2057
  if (choice === 'i') { return { next: 'import-picker' }; }
2088
2058
  if (choice === 'q' || choice === 'exit') { return { next: 'exit' }; }
2089
2059
 
@@ -2097,26 +2067,8 @@ async function newSessionScreen(rl, ask) {
2097
2067
  const input = (await ask('\n What do you want to do? ')).trim();
2098
2068
  if (!input) { return { next: 'main' }; }
2099
2069
 
2100
- const profile = loadProfile(cwd);
2101
- const detection = detectTask({ prompt: input });
2102
- const decision = decideRoute({ profile, detection, cwd });
2103
-
2104
- console.log(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})`);
2105
- console.log(` Reason: ${decision.explanation}\n`);
2106
-
2107
- const { spawnSync } = await import('node:child_process');
2108
- const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
2109
- if (launchTool === 'codex') {
2110
- spawnSync('codex', [input], { stdio: 'inherit' });
2111
- } else {
2112
- spawnSync('claude', ['-p', input], { stdio: 'inherit' });
2113
- }
2114
-
2115
- // After session ends, capture the most-recent session ID so [c] can resume it
2116
- const freshSessions = importReplitSessions(cwd);
2117
- if (freshSessions.length > 0) {
2118
- saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
2119
- }
2070
+ // All work routes through pipeline — detect → decide → dispatch with mandatory gates.
2071
+ await cmdGo([input], { cwd });
2120
2072
 
2121
2073
  return { next: 'main' };
2122
2074
  }
@@ -4094,27 +4046,11 @@ async function runScreens(startScreen = 'dashboard') {
4094
4046
  let current = startScreen;
4095
4047
  let ctx = {};
4096
4048
  while (current && current !== 'exit') {
4097
- // Handle type-to-start dispatch from mainScreen
4049
+ // Handle type-to-start dispatch from mainScreen — all work routes through pipeline.
4098
4050
  if (current === 'go' && ctx.prompt) {
4099
4051
  const prompt = ctx.prompt;
4100
- const cwd = process.cwd();
4101
- const profile = loadProfile(cwd);
4102
- const detection = detectTask({ prompt });
4103
- const decision = decideRoute({ profile, detection, cwd });
4104
- process.stdout.write(`\n Routing: ${decision.provider}/${decision.model} (${decision.tier})\n`);
4105
- process.stdout.write(` Reason: ${decision.explanation}\n\n`);
4106
- const { spawnSync } = await import('node:child_process');
4107
- const launchTool = decision.provider === 'openai' ? 'codex' : 'claude';
4108
- if (launchTool === 'codex') {
4109
- spawnSync('codex', [prompt], { stdio: 'inherit' });
4110
- } else {
4111
- spawnSync('claude', ['-p', prompt], { stdio: 'inherit' });
4112
- }
4113
- const freshSessions = importReplitSessions(cwd);
4114
- if (freshSessions.length > 0) {
4115
- saveTerminalState(cwd, getTerminalId(), freshSessions[0].id, launchTool);
4116
- }
4117
- await offerAutoCommit(cwd);
4052
+ const dryRun = ctx.dryRun || false;
4053
+ await cmdGo([prompt], { dryRun });
4118
4054
  current = 'main';
4119
4055
  ctx = {};
4120
4056
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/doctor.mjs CHANGED
@@ -1,10 +1,20 @@
1
1
  /**
2
- * doctor.mjs — Internal honesty checker for dual-brain development.
3
- * NOT for npm users. For developers working on this repo.
2
+ * doctor.mjs — Diagnostic and recovery stage in the dual-brain pipeline.
3
+ * Doctor is a diagnostic/recovery stage in the pipeline. It proposes, never implements.
4
4
  *
5
- * Exports: runDoctor, formatDoctorReport, scanClaims, checkDecisions,
6
- * checkFoundations, checkRoleBoundaries, checkEvidence, checkTokenWaste,
7
- * runHealthCheck, formatHealthReport, compareHealth
5
+ * Doctor can diagnose problems and propose recovery actions, but it NEVER directly
6
+ * edits files, dispatches agents, or runs commands. All proposals are returned as
7
+ * data for the pipeline to execute through its normal gated flow.
8
+ *
9
+ * Pipeline interface:
10
+ * doctorDiagnose(run) — pre-execution diagnostic check
11
+ * doctorRecover(run, failure) — post-failure recovery proposal
12
+ *
13
+ * Internal honesty checks (for developers working on this repo):
14
+ * runDoctor, formatDoctorReport, scanClaims, checkDecisions,
15
+ * checkFoundations, checkRoleBoundaries, checkEvidence, checkTokenWaste,
16
+ * runHealthCheck, formatHealthReport, compareHealth,
17
+ * doctorDiagnose, doctorRecover
8
18
  */
9
19
 
10
20
  import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
@@ -372,6 +382,173 @@ export function formatHealthReport(results) {
372
382
  return out.join('\n');
373
383
  }
374
384
 
385
+ // ─── Pipeline Stage: Diagnose ─────────────────────────────────────────────────
386
+
387
+ /**
388
+ * Pipeline-compatible diagnostic check. Called before execution to surface
389
+ * blocking or advisory findings based on the current pipeline run context.
390
+ *
391
+ * @param {object} run - PipelineRun object
392
+ * @param {object} run.context - Context pack (prompt, files, detection, profile, cwd)
393
+ * @param {object[]} run.failureHistory - Prior failures for this prompt fingerprint
394
+ * @param {object[]} run.priorOutcomes - Recent outcome records
395
+ * @param {object} run.plan - Execution plan (may be null before buildExecutionPlan)
396
+ * @returns {Promise<{
397
+ * findings: Array<{check: string, severity: string, message: string}>,
398
+ * canProceed: boolean,
399
+ * suggestedFixes: string[],
400
+ * blockedApproaches: string[]
401
+ * }>}
402
+ */
403
+ export async function doctorDiagnose(run) {
404
+ const { context = {}, failureHistory = [], priorOutcomes = [], plan = null } = run;
405
+ const cwd = context.cwd ?? process.cwd();
406
+
407
+ const findings = [];
408
+ const suggestedFixes = [];
409
+
410
+ // ── Role boundary check: pull from audit log ──────────────────────────────
411
+ const roleBoundaries = await checkRoleBoundaries(cwd);
412
+ for (const rb of roleBoundaries) {
413
+ findings.push({ check: 'role-boundaries', severity: rb.severity, message: rb.message });
414
+ }
415
+ if (roleBoundaries.length > 0) {
416
+ suggestedFixes.push('Dispatch search/work agents instead of using Read/Write/Bash directly from HEAD.');
417
+ }
418
+
419
+ // ── Evidence integrity check ──────────────────────────────────────────────
420
+ const evidenceIssues = await checkEvidence(cwd);
421
+ for (const ev of evidenceIssues) {
422
+ findings.push({ check: 'evidence', severity: ev.severity, message: ev.message });
423
+ }
424
+ if (evidenceIssues.some(e => e.type === 'false-file-claim')) {
425
+ suggestedFixes.push('Verify file claims match actual git state before recording outcomes as successful.');
426
+ }
427
+
428
+ // ── Token waste check ─────────────────────────────────────────────────────
429
+ const wasteIssues = await checkTokenWaste(cwd);
430
+ for (const tw of wasteIssues) {
431
+ findings.push({ check: 'token-waste', severity: tw.severity, message: tw.message });
432
+ }
433
+
434
+ // ── Foundation integrity check ────────────────────────────────────────────
435
+ const { issues: foundationIssues } = await checkFoundations(cwd);
436
+ for (const fi of foundationIssues) {
437
+ if (fi.type === 'dependent-on-invalidated') {
438
+ findings.push({
439
+ check: 'foundations',
440
+ severity: 'block',
441
+ message: `Active work depends on invalidated foundation "${fi.invalidatedFoundation}" via ${fi.file.join(', ')}`,
442
+ });
443
+ suggestedFixes.push(`Resolve dependency on invalidated foundation "${fi.invalidatedFoundation}" before proceeding.`);
444
+ }
445
+ }
446
+
447
+ // ── Repeated failure detection ────────────────────────────────────────────
448
+ const repeatFailures = failureHistory.filter(f => !f.resolved);
449
+ if (repeatFailures.length >= 2) {
450
+ findings.push({
451
+ check: 'failure-history',
452
+ severity: 'block',
453
+ message: `${repeatFailures.length} unresolved prior failures for this prompt — repeated approach likely to fail again.`,
454
+ });
455
+ suggestedFixes.push('Escalate to dual-brain think flow before retrying. Prior approaches must not be repeated.');
456
+ } else if (repeatFailures.length === 1) {
457
+ findings.push({
458
+ check: 'failure-history',
459
+ severity: 'warn',
460
+ message: '1 prior failure for this prompt — verify the approach differs before proceeding.',
461
+ });
462
+ }
463
+
464
+ // ── Risk/plan consistency check ───────────────────────────────────────────
465
+ if (plan && context.detection) {
466
+ const { risk } = context.detection;
467
+ if (risk === 'critical' && !plan.useChallenger) {
468
+ findings.push({
469
+ check: 'plan-consistency',
470
+ severity: 'warn',
471
+ message: 'Critical-risk task routed without challenger — dual-brain think is recommended.',
472
+ });
473
+ suggestedFixes.push('Enable challenger or run dual-brain think before executing critical-risk tasks.');
474
+ }
475
+ }
476
+
477
+ // ── Derive blocked approaches from failure history ────────────────────────
478
+ const blockedApproaches = repeatFailures
479
+ .filter(f => f.approach)
480
+ .map(f => f.approach);
481
+
482
+ const canProceed = !findings.some(f => f.severity === 'block');
483
+
484
+ return { findings, canProceed, suggestedFixes, blockedApproaches };
485
+ }
486
+
487
+ // ─── Pipeline Stage: Recover ──────────────────────────────────────────────────
488
+
489
+ /**
490
+ * Pipeline-compatible recovery proposer. Called when pipeline execution fails.
491
+ * Returns a recovery proposal for the pipeline to route — never executes directly.
492
+ *
493
+ * @param {object} run - PipelineRun object (same shape as doctorDiagnose)
494
+ * @param {object} failure - Failure context from the failed execution
495
+ * @param {string} [failure.error] - Error message
496
+ * @param {string} [failure.approach] - What was attempted
497
+ * @param {string} [failure.tier] - Tier that failed ('search'|'execute'|'think')
498
+ * @param {number} [failure.failCount] - How many times this has failed
499
+ * @returns {Promise<{
500
+ * proposal: string,
501
+ * avoidApproaches: string[],
502
+ * escalation: string|null
503
+ * }>}
504
+ */
505
+ export async function doctorRecover(run, failure = {}) {
506
+ const { failureHistory = [] } = run;
507
+ const { error = '', approach = '', tier = 'execute', failCount = 1 } = failure;
508
+
509
+ // Collect all previously failed approaches from history + this failure
510
+ const avoidApproaches = [
511
+ ...failureHistory.filter(f => f.approach).map(f => f.approach),
512
+ ...(approach ? [approach] : []),
513
+ ].filter(Boolean);
514
+
515
+ // Determine escalation: 2+ failures → dual-brain think
516
+ const totalFailures = failureHistory.filter(f => !f.resolved).length + 1;
517
+ const escalation = totalFailures >= 2 ? 'dual-brain' : null;
518
+
519
+ // Build a concrete recovery proposal without implementing anything
520
+ const proposalParts = [];
521
+
522
+ if (escalation === 'dual-brain') {
523
+ proposalParts.push(
524
+ `Escalate to dual-brain think flow: ${totalFailures} failures indicate the approach is fundamentally flawed.`,
525
+ 'Run: node .claude/hooks/dual-brain-think.mjs --question "<revised problem statement>"',
526
+ 'Do not retry the same implementation path.',
527
+ );
528
+ } else {
529
+ if (tier === 'search') {
530
+ proposalParts.push('Retry search with narrower scope or different file patterns.');
531
+ } else if (tier === 'execute') {
532
+ proposalParts.push(
533
+ 'Re-route through execute tier with a revised task description.',
534
+ error ? `Prior error was: ${error.slice(0, 120)}` : '',
535
+ );
536
+ } else if (tier === 'think') {
537
+ proposalParts.push('Re-run think tier with more context or an explicit constraint list.');
538
+ } else {
539
+ proposalParts.push('Retry with a revised task description that avoids the failed approach.');
540
+ }
541
+
542
+ if (avoidApproaches.length > 0) {
543
+ proposalParts.push(`Explicitly exclude these approaches: ${avoidApproaches.join(', ')}`);
544
+ }
545
+ }
546
+
547
+ const proposal = proposalParts.filter(Boolean).join(' ');
548
+
549
+ return { proposal, avoidApproaches, escalation };
550
+ }
551
+
375
552
  // ─── Health Baseline Comparison ───────────────────────────────────────────────
376
553
  export async function compareHealth(cwd = process.cwd()) {
377
554
  const bpath = join(cwd, '.dualbrain', 'health-baseline.json');
package/src/pipeline.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  // pipeline.mjs — Unified Pipeline for dual-brain.
3
3
  // Every feature (go, think, review, watch, auto-commit, pr-triage, wave) routes through here.
4
- // Exports: runPipeline, buildExecutionPlan, formatExecutionPlan
4
+ // Exports: runPipeline, buildExecutionPlan, formatExecutionPlan, createPipelineRun
5
+ // Gate exports: contextGate, planningGate, principleGate, executionGate, outcomeGate
5
6
 
6
7
  import { execSync } from 'node:child_process';
8
+ import { randomUUID } from 'node:crypto';
7
9
  import { detectTask } from './detect.mjs';
8
10
  import { decideRoute, getWorkStyle, WORK_STYLES } from './decide.mjs';
9
11
  import { dispatch } from './dispatch.mjs';
@@ -11,6 +13,209 @@ import { loadProfile } from './profile.mjs';
11
13
  import { mkdirSync, writeFileSync } from 'node:fs';
12
14
  import { join } from 'node:path';
13
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
+
14
219
  // ─── Context Pack ─────────────────────────────────────────────────────────────
15
220
 
16
221
  /**
@@ -336,10 +541,12 @@ async function verify(result, plan, cwd) {
336
541
 
337
542
  // ─── Outcome recording ────────────────────────────────────────────────────────
338
543
 
339
- async function recordOutcomeSafe(plan, result, verification) {
544
+ async function recordOutcomeSafe(run) {
340
545
  try {
341
546
  const { recordOutcome } = await import('./outcome.mjs');
342
- await recordOutcome({ plan, result, verification });
547
+ const cwd = run.context?.cwd ?? process.cwd();
548
+ const recorded = await recordOutcome(run.plan, run.result, run.verification, cwd);
549
+ run.outcome = recorded;
343
550
  } catch {
344
551
  // outcome.mjs doesn't exist yet — silently skip
345
552
  }
@@ -371,6 +578,27 @@ async function _loadProfileSafe(cwd) {
371
578
  }
372
579
  }
373
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
+
374
602
  // ─── Main entry point ─────────────────────────────────────────────────────────
375
603
 
376
604
  /**
@@ -386,7 +614,7 @@ async function _loadProfileSafe(cwd) {
386
614
  * @param {string} [options.forceDepth] Override reasoning depth
387
615
  * @param {boolean} [options.forceChallenger] Force dual-brain challenger
388
616
  * @param {boolean} [options.silent] Suppress all output
389
- * @returns {Promise<{ plan: object, result: object|null, verification: object|null }>}
617
+ * @returns {Promise<{ plan: object, result: object|null, verification: object|null } | { success: false, gateFailure: string, reason: string, run: object } | { success: true, run: object }>}
390
618
  */
391
619
  export async function runPipeline(trigger, prompt, options = {}) {
392
620
  const {
@@ -401,67 +629,131 @@ export async function runPipeline(trigger, prompt, options = {}) {
401
629
 
402
630
  const log = silent ? () => {} : (msg) => process.stderr.write(msg + '\n');
403
631
 
404
- let contextPack, plan, result = null, verification = null;
632
+ // Create the PipelineRun state object
633
+ const run = createPipelineRun(trigger, prompt);
405
634
 
406
635
  try {
407
- // ── Step 1: Context Pack ─────────────────────────────────────────────────
408
- contextPack = await buildContextPack(prompt, files, cwd);
636
+ // ── Phase 1: Context ──────────────────────────────────────────────────────
637
+
638
+ // Build context pack
639
+ run.context = await buildContextPack(prompt, files, cwd);
409
640
 
410
- // ── Step 2: Execution Plan ───────────────────────────────────────────────
411
- plan = buildExecutionPlan(contextPack, trigger, { forceDepth, forceChallenger });
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 });
412
668
 
413
669
  if (verbose || dryRun) {
414
- log(formatExecutionPlan(plan));
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 };
415
683
  }
416
684
 
417
685
  if (dryRun) {
418
- return { plan, result: null, verification: null };
686
+ run.completedAt = Date.now();
687
+ // Return legacy-compatible shape for dry-run callers
688
+ return { plan: run.plan, result: null, verification: null, run };
419
689
  }
420
690
 
421
- // ── Step 3: Checkpoint (best-effort, before execute) ────────────────────
422
- if (plan.checkpointRequired) {
423
- await createCheckpoint(cwd, contextPack);
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 };
424
695
  }
425
696
 
426
- // ── Step 4: Execute ──────────────────────────────────────────────────────
427
- const decision = {
428
- ...plan._decision,
429
- // Pass reasoning depth as a hint; dispatch uses effort from decision
430
- };
697
+ // ── Phase 3: Execute ──────────────────────────────────────────────────────
431
698
 
432
- result = await dispatch({
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({
433
707
  decision,
434
708
  prompt,
435
709
  files,
436
710
  cwd,
437
711
  dryRun: false,
438
712
  verbose,
439
- profile: contextPack.profile,
713
+ profile: run.context.profile,
440
714
  });
441
715
 
442
- // ── Step 5: Verify ───────────────────────────────────────────────────────
443
- verification = await verify(result, plan, cwd);
716
+ // ── Phase 4: Verification ─────────────────────────────────────────────────
717
+
718
+ run.verification = await verify(run.result, run.plan, cwd);
444
719
 
445
720
  if (verbose) {
446
- log(`[pipeline] verification: ${verification.ok ? 'ok' : 'failed'}`);
447
- for (const note of verification.notes) log(`[pipeline] ${note}`);
721
+ log(`[pipeline] verification: ${run.verification.ok ? 'ok' : 'failed'}`);
722
+ for (const note of run.verification.notes) log(`[pipeline] ${note}`);
448
723
  }
449
724
 
450
- if (!verification.ok) {
725
+ if (!run.verification.ok) {
451
726
  _incrementFailureCache(prompt);
452
727
  }
453
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
+
454
739
  } catch (err) {
455
740
  log(`[pipeline] error in pipeline step: ${err.message}`);
456
- result = { status: 'error', error: err.message };
457
- verification = { ok: false, notes: [err.message] };
458
- if (contextPack) _incrementFailureCache(prompt);
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 };
459
746
  }
460
747
 
461
- // ── Step 6: Outcome Record ───────────────────────────────────────────────
462
- if (plan) {
463
- await recordOutcomeSafe(plan, result, verification);
464
- }
748
+ run.completedAt = Date.now();
465
749
 
466
- return { plan: plan ?? null, result, verification };
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
+ };
467
759
  }