dual-brain 0.2.4 → 0.2.6
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 +366 -43
- package/package.json +10 -2
- package/src/awareness.mjs +71 -6
- package/src/checkpoint.mjs +109 -0
- package/src/ci-triage.mjs +191 -0
- package/src/continuity.mjs +291 -0
- package/src/detect.mjs +38 -0
- package/src/dispatch.mjs +73 -7
- package/src/doctor.mjs +6 -6
- package/src/health.mjs +37 -0
- package/src/pipeline.mjs +60 -3
- package/src/pr-agent.mjs +214 -0
- package/src/profile.mjs +39 -124
- package/src/replit.mjs +1 -1
- package/src/repo.mjs +153 -0
package/src/dispatch.mjs
CHANGED
|
@@ -345,7 +345,6 @@ async function detectRuntime() {
|
|
|
345
345
|
claudeAvailable && codexAvailable ? 'claude-code'
|
|
346
346
|
: claudeAvailable ? 'claude-code'
|
|
347
347
|
: codexAvailable ? 'codex-cli'
|
|
348
|
-
: process.env.CLAUDE_API_KEY || process.env.ANTHROPIC_API_KEY ? 'standalone'
|
|
349
348
|
: 'none';
|
|
350
349
|
|
|
351
350
|
_runtimeCache = { claudeAvailable, codexAvailable, runtime };
|
|
@@ -675,6 +674,7 @@ async function dispatch(input = {}) {
|
|
|
675
674
|
// ── Resume brief injection ───────────────────────────────────────────────────
|
|
676
675
|
// Inject the last session's receipt as context when no situationBrief is already set.
|
|
677
676
|
// This closes the receipt → brief → next session loop automatically.
|
|
677
|
+
// Falls back to continuity.mjs handoffs when receipt.mjs returns nothing.
|
|
678
678
|
if (!input.situationBrief) {
|
|
679
679
|
try {
|
|
680
680
|
const { buildResumeBrief } = await import('./receipt.mjs');
|
|
@@ -683,6 +683,17 @@ async function dispatch(input = {}) {
|
|
|
683
683
|
input = { ...input, situationBrief: brief };
|
|
684
684
|
}
|
|
685
685
|
} catch { /* non-blocking */ }
|
|
686
|
+
|
|
687
|
+
// Continuity fallback: check handoff from continuity.mjs if still no brief
|
|
688
|
+
if (!input.situationBrief) {
|
|
689
|
+
try {
|
|
690
|
+
const { buildResumeBrief: buildHandoffBrief } = await import('./continuity.mjs');
|
|
691
|
+
const handoffBrief = buildHandoffBrief(cwd);
|
|
692
|
+
if (handoffBrief) {
|
|
693
|
+
input = { ...input, situationBrief: handoffBrief };
|
|
694
|
+
}
|
|
695
|
+
} catch { /* non-blocking */ }
|
|
696
|
+
}
|
|
686
697
|
}
|
|
687
698
|
// ── End resume brief injection ───────────────────────────────────────────────
|
|
688
699
|
|
|
@@ -848,6 +859,23 @@ async function dispatch(input = {}) {
|
|
|
848
859
|
}
|
|
849
860
|
}
|
|
850
861
|
|
|
862
|
+
// ── Worktree isolation decision ──────────────────────────────────────────────
|
|
863
|
+
// Compute whether this dispatch should run in an isolated worktree based on
|
|
864
|
+
// risk level, file-edit volume, and security/auth signals in the prompt.
|
|
865
|
+
const SECURITY_PATTERN = /\b(auth|secret|token|credential|password|key|oauth|jwt|session|permission|role|acl)\b/i;
|
|
866
|
+
const decisionRisk = (decision.risk ?? 'low').toLowerCase();
|
|
867
|
+
const decisionFilesEst = decision.filesEstimate ?? 0;
|
|
868
|
+
const riskIsElevated = decisionRisk === 'medium' || decisionRisk === 'high' || decisionRisk === 'critical';
|
|
869
|
+
const manyFiles = decisionFilesEst >= 3;
|
|
870
|
+
const hasSecurity = SECURITY_PATTERN.test(prompt);
|
|
871
|
+
const useWorktree = input.useWorktree ?? (riskIsElevated || manyFiles || hasSecurity);
|
|
872
|
+
|
|
873
|
+
// Propagate useWorktree onto effectiveDecision so callers can inspect it
|
|
874
|
+
if (useWorktree) {
|
|
875
|
+
effectiveDecision = { ...effectiveDecision, useWorktree: true };
|
|
876
|
+
}
|
|
877
|
+
// ── End worktree isolation decision ─────────────────────────────────────────
|
|
878
|
+
|
|
851
879
|
// ── Native Claude Code dispatch ──────────────────────────────────────────────
|
|
852
880
|
// When running inside Claude Code AND the provider is claude, execute via the
|
|
853
881
|
// claude CLI directly (foreground subprocess) so results are captured and returned.
|
|
@@ -856,7 +884,7 @@ async function dispatch(input = {}) {
|
|
|
856
884
|
const nativeDescriptor = buildNativeDispatch(
|
|
857
885
|
effectiveDecision,
|
|
858
886
|
prompt,
|
|
859
|
-
{ worktree:
|
|
887
|
+
{ worktree: useWorktree, maxTurns: input.maxTurns },
|
|
860
888
|
);
|
|
861
889
|
|
|
862
890
|
const command = buildCommand(effectiveDecision, prompt, files, cwd);
|
|
@@ -955,6 +983,23 @@ async function dispatch(input = {}) {
|
|
|
955
983
|
success,
|
|
956
984
|
});
|
|
957
985
|
|
|
986
|
+
// ── Auto-review annotation ────────────────────────────────────────────────
|
|
987
|
+
// When execution changed files at medium+ risk, stamp result with a pending
|
|
988
|
+
// review note. The opposite provider from the one that did the work reviews
|
|
989
|
+
// it (true dual-brain). Non-blocking — does not delay the return value.
|
|
990
|
+
let autoReview;
|
|
991
|
+
if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
|
|
992
|
+
try {
|
|
993
|
+
const reviewProvider = currentProvider === 'claude' ? 'openai' : 'claude';
|
|
994
|
+
autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
|
|
995
|
+
} catch {
|
|
996
|
+
autoReview = { triggered: false, reason: 'review-dispatch-failed' };
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
|
|
1000
|
+
}
|
|
1001
|
+
// ── End auto-review annotation ────────────────────────────────────────────
|
|
1002
|
+
|
|
958
1003
|
return {
|
|
959
1004
|
status: success ? 'completed' : 'failed',
|
|
960
1005
|
type: 'native-agent',
|
|
@@ -967,6 +1012,8 @@ async function dispatch(input = {}) {
|
|
|
967
1012
|
summary,
|
|
968
1013
|
durationMs,
|
|
969
1014
|
usage,
|
|
1015
|
+
worktreeUsed: useWorktree,
|
|
1016
|
+
autoReview,
|
|
970
1017
|
error: success ? null : errorText.slice(0, 200),
|
|
971
1018
|
};
|
|
972
1019
|
}
|
|
@@ -1054,16 +1101,35 @@ async function dispatch(input = {}) {
|
|
|
1054
1101
|
success,
|
|
1055
1102
|
});
|
|
1056
1103
|
|
|
1104
|
+
// ── Auto-review annotation ──────────────────────────────────────────────────
|
|
1105
|
+
// When execution changed files at medium+ risk, stamp result with a pending
|
|
1106
|
+
// review note. The opposite provider from the one that did the work reviews
|
|
1107
|
+
// it (true dual-brain). Non-blocking — does not delay the return value.
|
|
1108
|
+
let autoReview;
|
|
1109
|
+
if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
|
|
1110
|
+
try {
|
|
1111
|
+
const reviewProvider = subProvider === 'claude' ? 'openai' : 'claude';
|
|
1112
|
+
autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
|
|
1113
|
+
} catch {
|
|
1114
|
+
autoReview = { triggered: false, reason: 'review-dispatch-failed' };
|
|
1115
|
+
}
|
|
1116
|
+
} else {
|
|
1117
|
+
autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
|
|
1118
|
+
}
|
|
1119
|
+
// ── End auto-review annotation ──────────────────────────────────────────────
|
|
1120
|
+
|
|
1057
1121
|
return {
|
|
1058
|
-
status:
|
|
1059
|
-
provider:
|
|
1060
|
-
model:
|
|
1061
|
-
specialist:
|
|
1062
|
-
command:
|
|
1122
|
+
status: success ? 'completed' : 'failed',
|
|
1123
|
+
provider: subProvider,
|
|
1124
|
+
model: subModel,
|
|
1125
|
+
specialist: specialist ?? 'generic',
|
|
1126
|
+
command: subCommand,
|
|
1063
1127
|
exitCode,
|
|
1064
1128
|
summary,
|
|
1065
1129
|
durationMs,
|
|
1066
1130
|
usage,
|
|
1131
|
+
worktreeUsed: useWorktree,
|
|
1132
|
+
autoReview,
|
|
1067
1133
|
error: success ? null : errorText.slice(0, 200),
|
|
1068
1134
|
};
|
|
1069
1135
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -564,13 +564,13 @@ const VERIFIERS = {
|
|
|
564
564
|
try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'claude CLI found', probe: 'which claude' }; }
|
|
565
565
|
catch { return { status: 'failed', evidence: 'claude CLI not found', probe: 'which claude' }; }
|
|
566
566
|
}},
|
|
567
|
-
'openai-key': { ttl:
|
|
568
|
-
|
|
569
|
-
return { status:
|
|
567
|
+
'openai-key': { ttl: TTL_TOOL, fn: () => {
|
|
568
|
+
try { execSync('which codex', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'codex CLI found (subscription auth)', probe: 'which codex' }; }
|
|
569
|
+
catch { return { status: 'failed', evidence: 'codex CLI not found — run: codex login', probe: 'which codex' }; }
|
|
570
570
|
}},
|
|
571
|
-
'anthropic-key': { ttl:
|
|
572
|
-
|
|
573
|
-
return { status:
|
|
571
|
+
'anthropic-key': { ttl: TTL_TOOL, fn: () => {
|
|
572
|
+
try { execSync('which claude', { stdio: 'pipe', timeout: 2000 }); return { status: 'verified', evidence: 'claude CLI found (subscription auth)', probe: 'which claude' }; }
|
|
573
|
+
catch { return { status: 'failed', evidence: 'claude CLI not found — run: claude login', probe: 'which claude' }; }
|
|
574
574
|
}},
|
|
575
575
|
'git-available': { ttl: TTL_TOOL, fn: () => {
|
|
576
576
|
try { const v = execSync('git --version', { stdio: 'pipe', timeout: 2000 }).toString().trim(); return { status: 'verified', evidence: v, probe: 'git --version' }; }
|
package/src/health.mjs
CHANGED
|
@@ -94,6 +94,8 @@ export async function getAuthHealthStatus(cwd) {
|
|
|
94
94
|
return { ok: false, detail: 'Auth: no credentials found (direct check)', source: 'unknown' };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
|
98
|
+
|
|
97
99
|
const HEALTH_FILE = '.dualbrain/health.json';
|
|
98
100
|
|
|
99
101
|
// Cooldown ladder in minutes: index = attempts - 1, capped at last entry
|
|
@@ -314,6 +316,41 @@ export function resetHealth(cwd) {
|
|
|
314
316
|
saveRaw({ states: {}, session: null }, cwd);
|
|
315
317
|
}
|
|
316
318
|
|
|
319
|
+
// ─── Network timeout guard ────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Ping a provider URL with a bounded timeout so slow networks don't hang the CLI.
|
|
323
|
+
*
|
|
324
|
+
* Uses AbortController to enforce the deadline. On timeout or network error the
|
|
325
|
+
* caller receives { ok: false, status: 'timeout' } rather than hanging forever.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} url
|
|
328
|
+
* @param {{ timeoutMs?: number, headers?: Record<string,string> }} [opts]
|
|
329
|
+
* @returns {Promise<{ ok: boolean, status: 'ok'|'timeout'|'error', detail?: string }>}
|
|
330
|
+
*/
|
|
331
|
+
export async function pingProvider(url, opts = {}) {
|
|
332
|
+
const timeoutMs = opts.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
|
|
333
|
+
const controller = new AbortController();
|
|
334
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
335
|
+
try {
|
|
336
|
+
const res = await fetch(url, {
|
|
337
|
+
method: 'HEAD',
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
headers: opts.headers ?? {},
|
|
340
|
+
});
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
return { ok: res.ok, status: 'ok', detail: String(res.status) };
|
|
343
|
+
} catch (err) {
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
const isTimeout = err?.name === 'AbortError';
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
status: isTimeout ? 'timeout' : 'error',
|
|
349
|
+
detail: isTimeout ? `Provider health: unknown (timeout after ${timeoutMs}ms)` : String(err?.message),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
317
354
|
// ─── Remaining cooldown helper (used by status display) ──────────────────────
|
|
318
355
|
|
|
319
356
|
/**
|
package/src/pipeline.mjs
CHANGED
|
@@ -84,6 +84,9 @@ export function createPipelineRun(trigger = '', prompt = '') {
|
|
|
84
84
|
replitTools: null, // from replit.inspectReplitTools()
|
|
85
85
|
replitConfig: null, // from replit.getReplitToolsConfig()
|
|
86
86
|
|
|
87
|
+
// Execution safety (populated in Phase 3 when risk is high/critical)
|
|
88
|
+
checkpoint: null, // from checkpoint.mjs — { success, id, label, timestamp } or null
|
|
89
|
+
|
|
87
90
|
completedAt: null,
|
|
88
91
|
};
|
|
89
92
|
}
|
|
@@ -873,8 +876,8 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
873
876
|
try {
|
|
874
877
|
const { suggestModel, getRegistryAge } = await import('./models.mjs');
|
|
875
878
|
const availableProviders = [];
|
|
876
|
-
if (run.environment?.
|
|
877
|
-
if (run.environment?.
|
|
879
|
+
if (run.environment?.claudeCode?.isInsideClaude || run.environment?.tools?.claude?.available) availableProviders.push('anthropic');
|
|
880
|
+
if (run.environment?.tools?.codex?.available) availableProviders.push('openai');
|
|
878
881
|
|
|
879
882
|
const intent = run.promptAnalysis?.intent?.type || 'execute';
|
|
880
883
|
const risk = run.plan?.risk || 'medium';
|
|
@@ -978,11 +981,28 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
978
981
|
|
|
979
982
|
// ── Phase 3: Execute ──────────────────────────────────────────────────────
|
|
980
983
|
|
|
981
|
-
// Checkpoint (best-effort, before execute)
|
|
984
|
+
// Checkpoint (best-effort, before execute).
|
|
985
|
+
// The pipeline-internal createCheckpoint handles git stash/HEAD recording.
|
|
986
|
+
// Additionally, use the dedicated checkpoint.mjs module for high/critical risk
|
|
987
|
+
// tasks so the result is surfaced in the run object.
|
|
982
988
|
if (run.plan.checkpointRequired) {
|
|
983
989
|
await createCheckpoint(cwd, run.context);
|
|
984
990
|
}
|
|
985
991
|
|
|
992
|
+
const detectedRisk = run.context?.detection?.risk ?? 'low';
|
|
993
|
+
if (detectedRisk === 'high' || detectedRisk === 'critical') {
|
|
994
|
+
try {
|
|
995
|
+
const { createCheckpoint: cpCreate } = await import('./checkpoint.mjs');
|
|
996
|
+
const cpLabel = `before: ${prompt.slice(0, 80)}`;
|
|
997
|
+
const cpResult = cpCreate(cpLabel, { cwd });
|
|
998
|
+
run.checkpoint = cpResult;
|
|
999
|
+
if (verbose) log(`[pipeline] checkpoint created: ${cpResult.id} (${cpResult.success ? 'ok' : 'failed'})`);
|
|
1000
|
+
} catch {
|
|
1001
|
+
// checkpoint.mjs unavailable — non-blocking
|
|
1002
|
+
run.checkpoint = null;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
986
1006
|
const decision = { ...run.plan._decision };
|
|
987
1007
|
|
|
988
1008
|
run.result = await dispatch({
|
|
@@ -1157,6 +1177,41 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1157
1177
|
}
|
|
1158
1178
|
}
|
|
1159
1179
|
|
|
1180
|
+
// Continuity handoff — generate and persist a compact receipt so the next
|
|
1181
|
+
// session can resume seamlessly (survives context limits and crashes).
|
|
1182
|
+
try {
|
|
1183
|
+
const { generateHandoff, saveHandoff, pruneHandoffs } = await import('./continuity.mjs');
|
|
1184
|
+
const handoffCwd = options.cwd || process.cwd();
|
|
1185
|
+
|
|
1186
|
+
const sessionState = {
|
|
1187
|
+
taskDescription: prompt.slice(0, 200),
|
|
1188
|
+
filesChanged: run.result?.filesChanged || run.plan?.targetFiles || [],
|
|
1189
|
+
testsRun: run.verification?.notes || [],
|
|
1190
|
+
decisions: run.plan ? [{
|
|
1191
|
+
provider: run.plan.primaryProvider,
|
|
1192
|
+
model: run.plan.primaryModel,
|
|
1193
|
+
tier: run.plan.tier,
|
|
1194
|
+
reasoningDepth: run.plan.reasoningDepth,
|
|
1195
|
+
}] : [],
|
|
1196
|
+
unresolved: run.contradictions?.filter(c => c.severity !== 'block').map(c => c.message) || [],
|
|
1197
|
+
routingHistory: {
|
|
1198
|
+
lastProvider: run.result?.provider || run.plan?.primaryProvider || null,
|
|
1199
|
+
lastModel: run.result?.model || run.plan?.primaryModel || null,
|
|
1200
|
+
failedProviders: run.result?.error ? [run.plan?.primaryProvider].filter(Boolean) : [],
|
|
1201
|
+
},
|
|
1202
|
+
activePreferences: run.context?.profile?.preferences || [],
|
|
1203
|
+
resumeHint: run.result && !run.result?.error
|
|
1204
|
+
? null
|
|
1205
|
+
: `retry: ${prompt.slice(0, 100)}`,
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
const handoff = generateHandoff(sessionState);
|
|
1209
|
+
saveHandoff(handoff, handoffCwd);
|
|
1210
|
+
pruneHandoffs(handoffCwd, 10); // keep last 10 handoffs
|
|
1211
|
+
} catch {
|
|
1212
|
+
// continuity is best-effort — never block pipeline completion
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1160
1215
|
} catch (err) {
|
|
1161
1216
|
log(`[pipeline] error in pipeline step: ${err.message}`);
|
|
1162
1217
|
run.result = { status: 'error', error: err.message };
|
|
@@ -1180,6 +1235,8 @@ export async function runPipeline(trigger, prompt, options = {}) {
|
|
|
1180
1235
|
modelSuggestion: run.modelSuggestion,
|
|
1181
1236
|
thinkResult: run.thinkResult,
|
|
1182
1237
|
decisionPreflight: run.decisionPreflight,
|
|
1238
|
+
// Execution safety
|
|
1239
|
+
checkpoint: run.checkpoint,
|
|
1183
1240
|
// Legacy compatibility
|
|
1184
1241
|
plan: run.plan,
|
|
1185
1242
|
result: run.result,
|
package/src/pr-agent.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// pr-agent.mjs — PR workflow module for dual-brain.
|
|
2
|
+
// Provides issue/task → branch → implement → PR automation using the gh CLI.
|
|
3
|
+
// Exports: hasGitHub, getBranchInfo, createBranch, getDiffSummary, createPR,
|
|
4
|
+
// listPRs, getPRDetails, buildPRBody
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if gh CLI is available and authenticated.
|
|
12
|
+
* @returns {{ available: boolean, authenticated: boolean }}
|
|
13
|
+
*/
|
|
14
|
+
export function hasGitHub() {
|
|
15
|
+
try {
|
|
16
|
+
execSync('gh auth status', { stdio: 'pipe', timeout: 5000 });
|
|
17
|
+
return { available: true, authenticated: true };
|
|
18
|
+
} catch {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which gh', { stdio: 'pipe', timeout: 2000 });
|
|
21
|
+
return { available: true, authenticated: false };
|
|
22
|
+
} catch {
|
|
23
|
+
return { available: false, authenticated: false };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get current branch info including distance from default branch.
|
|
30
|
+
* @param {string} [cwd]
|
|
31
|
+
* @returns {{ branch: string|null, defaultBranch: string, ahead: number, behind: number, isDefault: boolean }}
|
|
32
|
+
*/
|
|
33
|
+
export function getBranchInfo(cwd) {
|
|
34
|
+
const dir = cwd ?? process.cwd();
|
|
35
|
+
try {
|
|
36
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim();
|
|
37
|
+
const defaultBranch = execSync(
|
|
38
|
+
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo refs/remotes/origin/main',
|
|
39
|
+
{ cwd: dir, encoding: 'utf8', timeout: 3000 },
|
|
40
|
+
).trim().replace('refs/remotes/origin/', '');
|
|
41
|
+
const ahead = parseInt(
|
|
42
|
+
execSync(`git rev-list --count ${defaultBranch}..HEAD 2>/dev/null || echo 0`, { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim(),
|
|
43
|
+
) || 0;
|
|
44
|
+
const behind = parseInt(
|
|
45
|
+
execSync(`git rev-list --count HEAD..${defaultBranch} 2>/dev/null || echo 0`, { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim(),
|
|
46
|
+
) || 0;
|
|
47
|
+
return { branch, defaultBranch, ahead, behind, isDefault: branch === defaultBranch };
|
|
48
|
+
} catch {
|
|
49
|
+
return { branch: null, defaultBranch: 'main', ahead: 0, behind: 0, isDefault: true };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a feature branch from a task description.
|
|
55
|
+
* Branch name is prefixed with "db/" and slugified from the description.
|
|
56
|
+
* @param {string} taskDescription
|
|
57
|
+
* @param {string} [cwd]
|
|
58
|
+
* @returns {{ success: boolean, branch: string, error?: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function createBranch(taskDescription, cwd) {
|
|
61
|
+
const dir = cwd ?? process.cwd();
|
|
62
|
+
const slug = taskDescription
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
65
|
+
.trim()
|
|
66
|
+
.replace(/\s+/g, '-')
|
|
67
|
+
.slice(0, 50);
|
|
68
|
+
const branchName = `db/${slug}`;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
execSync(`git checkout -b "${branchName}"`, { cwd: dir, stdio: 'pipe', timeout: 5000 });
|
|
72
|
+
return { success: true, branch: branchName };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { success: false, branch: branchName, error: err.message };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get diff summary for PR description generation.
|
|
80
|
+
* @param {string} baseBranch Base branch name (e.g. 'main')
|
|
81
|
+
* @param {string} [cwd]
|
|
82
|
+
* @returns {{ stat: string, files: string[], summary: string, fileCount: number }}
|
|
83
|
+
*/
|
|
84
|
+
export function getDiffSummary(baseBranch, cwd) {
|
|
85
|
+
const dir = cwd ?? process.cwd();
|
|
86
|
+
try {
|
|
87
|
+
const stat = execSync(`git diff --stat ${baseBranch}...HEAD`, { cwd: dir, encoding: 'utf8', timeout: 10000 }).trim();
|
|
88
|
+
const files = execSync(`git diff --name-only ${baseBranch}...HEAD`, { cwd: dir, encoding: 'utf8', timeout: 5000 })
|
|
89
|
+
.trim()
|
|
90
|
+
.split('\n')
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
const summary = execSync(`git diff --shortstat ${baseBranch}...HEAD`, { cwd: dir, encoding: 'utf8', timeout: 5000 }).trim();
|
|
93
|
+
return { stat, files, summary, fileCount: files.length };
|
|
94
|
+
} catch {
|
|
95
|
+
return { stat: '', files: [], summary: '', fileCount: 0 };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a PR using the gh CLI. Pushes the current branch first.
|
|
101
|
+
* @param {object} opts
|
|
102
|
+
* @param {string} opts.title
|
|
103
|
+
* @param {string} opts.body
|
|
104
|
+
* @param {string} [opts.baseBranch]
|
|
105
|
+
* @param {boolean} [opts.draft]
|
|
106
|
+
* @param {string[]} [opts.labels]
|
|
107
|
+
* @param {string} [opts.cwd]
|
|
108
|
+
* @returns {{ success: boolean, url?: string, error?: string }}
|
|
109
|
+
*/
|
|
110
|
+
export function createPR(opts) {
|
|
111
|
+
const { title, body, baseBranch, draft, labels, cwd } = opts;
|
|
112
|
+
const dir = cwd ?? process.cwd();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// Push current branch to origin first
|
|
116
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: dir, encoding: 'utf8', timeout: 3000 }).trim();
|
|
117
|
+
execSync(`git push -u origin "${branch}"`, { cwd: dir, stdio: 'pipe', timeout: 30000 });
|
|
118
|
+
|
|
119
|
+
// Build gh pr create args
|
|
120
|
+
const args = ['gh', 'pr', 'create', '--title', JSON.stringify(title), '--body', JSON.stringify(body)];
|
|
121
|
+
if (baseBranch) args.push('--base', baseBranch);
|
|
122
|
+
if (draft) args.push('--draft');
|
|
123
|
+
if (labels?.length) args.push('--label', labels.join(','));
|
|
124
|
+
|
|
125
|
+
const result = execSync(args.join(' '), { cwd: dir, encoding: 'utf8', timeout: 30000 });
|
|
126
|
+
const url = result.trim();
|
|
127
|
+
return { success: true, url };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { success: false, error: err.message };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* List open (or other state) PRs for the current repo.
|
|
135
|
+
* @param {string} [cwd]
|
|
136
|
+
* @param {object} [opts]
|
|
137
|
+
* @param {'open'|'closed'|'merged'|'all'} [opts.state]
|
|
138
|
+
* @param {number} [opts.limit]
|
|
139
|
+
* @returns {object[]}
|
|
140
|
+
*/
|
|
141
|
+
export function listPRs(cwd, opts = {}) {
|
|
142
|
+
const dir = cwd ?? process.cwd();
|
|
143
|
+
const { state = 'open', limit = 10 } = opts;
|
|
144
|
+
try {
|
|
145
|
+
const json = execSync(
|
|
146
|
+
`gh pr list --state ${state} --limit ${limit} --json number,title,headRefName,author,createdAt,isDraft`,
|
|
147
|
+
{ cwd: dir, encoding: 'utf8', timeout: 10000 },
|
|
148
|
+
);
|
|
149
|
+
return JSON.parse(json);
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get PR details including diff stats, comments, and CI checks.
|
|
157
|
+
* @param {number|string} prNumber
|
|
158
|
+
* @param {string} [cwd]
|
|
159
|
+
* @returns {object|null}
|
|
160
|
+
*/
|
|
161
|
+
export function getPRDetails(prNumber, cwd) {
|
|
162
|
+
const dir = cwd ?? process.cwd();
|
|
163
|
+
try {
|
|
164
|
+
const json = execSync(
|
|
165
|
+
`gh pr view ${prNumber} --json title,body,headRefName,baseRefName,state,additions,deletions,changedFiles,reviews,comments,statusCheckRollup`,
|
|
166
|
+
{ cwd: dir, encoding: 'utf8', timeout: 10000 },
|
|
167
|
+
);
|
|
168
|
+
return JSON.parse(json);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a PR body from a task description and dispatch results.
|
|
176
|
+
* @param {string} taskDescription
|
|
177
|
+
* @param {object} results Dispatch result object (filesChanged, testsRun, decisions, etc.)
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
export function buildPRBody(taskDescription, results) {
|
|
181
|
+
const lines = [];
|
|
182
|
+
lines.push('## Summary');
|
|
183
|
+
lines.push(taskDescription);
|
|
184
|
+
lines.push('');
|
|
185
|
+
|
|
186
|
+
if (results.filesChanged?.length) {
|
|
187
|
+
lines.push('## Changes');
|
|
188
|
+
for (const f of results.filesChanged) {
|
|
189
|
+
lines.push(`- \`${f}\``);
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (results.testsRun?.length) {
|
|
195
|
+
lines.push('## Tests');
|
|
196
|
+
for (const t of results.testsRun) {
|
|
197
|
+
lines.push(`- ${t}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (results.decisions?.length) {
|
|
203
|
+
lines.push('## Routing');
|
|
204
|
+
for (const d of results.decisions) {
|
|
205
|
+
lines.push(`- ${d}`);
|
|
206
|
+
}
|
|
207
|
+
lines.push('');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
lines.push('---');
|
|
211
|
+
lines.push('Generated by [dual-brain](https://npmjs.com/package/dual-brain)');
|
|
212
|
+
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|