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.
- package/bin/dual-brain.mjs +14 -78
- package/package.json +1 -1
- package/src/doctor.mjs +182 -5
- package/src/pipeline.mjs +326 -34
package/bin/dual-brain.mjs
CHANGED
|
@@ -909,7 +909,7 @@ function cmdBreakGlass(reason) {
|
|
|
909
909
|
// ─── Screen helpers ───────────────────────────────────────────────────────────
|
|
910
910
|
|
|
911
911
|
/**
|
|
912
|
-
* Render the
|
|
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 = `
|
|
927
|
+
const credit = `dual-brain`;
|
|
928
928
|
|
|
929
929
|
const allProviderLines = [...providerLines];
|
|
930
930
|
if (dtVersion) {
|
|
931
|
-
allProviderLines.push(`📦
|
|
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📦
|
|
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 —
|
|
1841
|
-
const actionsContent = '
|
|
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
|
|
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'
|
|
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
|
-
|
|
2101
|
-
|
|
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
|
|
4101
|
-
|
|
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
package/src/doctor.mjs
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* doctor.mjs —
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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(
|
|
544
|
+
async function recordOutcomeSafe(run) {
|
|
340
545
|
try {
|
|
341
546
|
const { recordOutcome } = await import('./outcome.mjs');
|
|
342
|
-
|
|
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
|
-
|
|
632
|
+
// Create the PipelineRun state object
|
|
633
|
+
const run = createPipelineRun(trigger, prompt);
|
|
405
634
|
|
|
406
635
|
try {
|
|
407
|
-
// ──
|
|
408
|
-
|
|
636
|
+
// ── Phase 1: Context ──────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
// Build context pack
|
|
639
|
+
run.context = await buildContextPack(prompt, files, cwd);
|
|
409
640
|
|
|
410
|
-
//
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
422
|
-
if (
|
|
423
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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:
|
|
713
|
+
profile: run.context.profile,
|
|
440
714
|
});
|
|
441
715
|
|
|
442
|
-
// ──
|
|
443
|
-
|
|
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 (
|
|
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
|
-
|
|
462
|
-
if (plan) {
|
|
463
|
-
await recordOutcomeSafe(plan, result, verification);
|
|
464
|
-
}
|
|
748
|
+
run.completedAt = Date.now();
|
|
465
749
|
|
|
466
|
-
|
|
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
|
}
|