dual-brain 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Detect CI system in use.
7
+ * @param {string} [cwd]
8
+ * @returns {{ systems: string[], primary: string|null }}
9
+ */
10
+ export function detectCI(cwd) {
11
+ const root = cwd || process.cwd();
12
+ const systems = [];
13
+
14
+ if (existsSync(join(root, '.github/workflows'))) systems.push('github-actions');
15
+ if (existsSync(join(root, '.circleci'))) systems.push('circleci');
16
+ if (existsSync(join(root, '.gitlab-ci.yml'))) systems.push('gitlab-ci');
17
+ if (existsSync(join(root, 'Jenkinsfile'))) systems.push('jenkins');
18
+ if (existsSync(join(root, '.travis.yml'))) systems.push('travis');
19
+ if (existsSync(join(root, 'vercel.json')) || existsSync(join(root, '.vercel'))) systems.push('vercel');
20
+ if (existsSync(join(root, 'netlify.toml'))) systems.push('netlify');
21
+
22
+ return { systems, primary: systems[0] || null };
23
+ }
24
+
25
+ /**
26
+ * Get recent CI run status using gh CLI.
27
+ * @param {string} [cwd]
28
+ * @returns {{ available: boolean, runs: object[], hasFailures: boolean, lastRun: object|null }}
29
+ */
30
+ export function getCIStatus(cwd) {
31
+ try {
32
+ const json = execSync(
33
+ 'gh run list --limit 5 --json databaseId,name,status,conclusion,headBranch,createdAt',
34
+ { cwd, encoding: 'utf8', timeout: 10000 }
35
+ );
36
+ const runs = JSON.parse(json);
37
+ return {
38
+ available: true,
39
+ runs: runs.map(r => ({
40
+ id: r.databaseId,
41
+ name: r.name,
42
+ status: r.status,
43
+ conclusion: r.conclusion,
44
+ branch: r.headBranch,
45
+ createdAt: r.createdAt,
46
+ })),
47
+ hasFailures: runs.some(r => r.conclusion === 'failure'),
48
+ lastRun: runs[0] || null,
49
+ };
50
+ } catch {
51
+ return { available: false, runs: [], hasFailures: false, lastRun: null };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Get failed CI run logs and classify the failure.
57
+ * @param {string|number} runId
58
+ * @param {string} [cwd]
59
+ * @returns {object}
60
+ */
61
+ export function triageFailure(runId, cwd) {
62
+ try {
63
+ const logs = execSync(`gh run view ${runId} --log-failed 2>/dev/null | tail -100`, {
64
+ cwd, encoding: 'utf8', timeout: 15000,
65
+ });
66
+
67
+ const classification = classifyFailure(logs);
68
+ const fileHints = extractFileHints(logs, cwd);
69
+
70
+ return {
71
+ success: true,
72
+ runId,
73
+ logs: logs.slice(-3000), // last 3000 chars
74
+ classification,
75
+ fileHints,
76
+ suggestedAction: getSuggestedAction(classification),
77
+ };
78
+ } catch (err) {
79
+ return { success: false, runId, error: err.message };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Classify a CI failure from log output.
85
+ * @param {string} logs
86
+ * @returns {{ type: string, confidence: string }}
87
+ */
88
+ function classifyFailure(logs) {
89
+ const lower = logs.toLowerCase();
90
+
91
+ if (lower.includes('syntaxerror') || lower.includes('parse error')) return { type: 'syntax', confidence: 'high' };
92
+ if (lower.includes('typeerror') || lower.includes('type error')) return { type: 'type-error', confidence: 'high' };
93
+ if (lower.includes('referenceerror')) return { type: 'reference-error', confidence: 'high' };
94
+ if (lower.includes('test fail') || lower.includes('tests failed') || lower.includes('assertion')) return { type: 'test-failure', confidence: 'high' };
95
+ if (lower.includes('enoent') || lower.includes('no such file')) return { type: 'missing-file', confidence: 'high' };
96
+ if (lower.includes('permission denied') || lower.includes('eacces')) return { type: 'permissions', confidence: 'high' };
97
+ if (lower.includes('timeout') || lower.includes('timed out')) return { type: 'timeout', confidence: 'medium' };
98
+ if (lower.includes('out of memory') || lower.includes('heap')) return { type: 'oom', confidence: 'medium' };
99
+ if (lower.includes('npm err') || lower.includes('yarn error') || lower.includes('dependency')) return { type: 'dependency', confidence: 'medium' };
100
+ if (lower.includes('lint') || lower.includes('eslint')) return { type: 'lint', confidence: 'high' };
101
+ if (lower.includes('build fail')) return { type: 'build', confidence: 'medium' };
102
+ if (lower.includes('docker') || lower.includes('container')) return { type: 'container', confidence: 'medium' };
103
+
104
+ return { type: 'unknown', confidence: 'low' };
105
+ }
106
+
107
+ /**
108
+ * Extract local file paths referenced in CI logs.
109
+ * @param {string} logs
110
+ * @param {string} [cwd]
111
+ * @returns {string[]}
112
+ */
113
+ function extractFileHints(logs, cwd) {
114
+ const files = new Set();
115
+ const root = cwd || process.cwd();
116
+
117
+ const patterns = [
118
+ /(?:at\s+)?([a-zA-Z0-9_./\\-]+\.[a-zA-Z]+):(\d+)/g,
119
+ /(?:in\s+)?([a-zA-Z0-9_./\\-]+\.[a-zA-Z]+)\((\d+)\)/g,
120
+ /Error in ([a-zA-Z0-9_./\\-]+\.[a-zA-Z]+)/g,
121
+ ];
122
+
123
+ for (const pattern of patterns) {
124
+ for (const match of logs.matchAll(pattern)) {
125
+ const file = match[1];
126
+ if (file && !file.includes('node_modules') && existsSync(join(root, file))) {
127
+ files.add(file);
128
+ }
129
+ }
130
+ }
131
+
132
+ return [...files];
133
+ }
134
+
135
+ /**
136
+ * Get a human-readable suggested action for a failure classification.
137
+ * @param {{ type: string }} classification
138
+ * @returns {string}
139
+ */
140
+ function getSuggestedAction(classification) {
141
+ const actions = {
142
+ 'syntax': 'Fix syntax error in the identified file',
143
+ 'type-error': 'Check type annotations and function signatures',
144
+ 'reference-error': 'Check for undefined variables or missing imports',
145
+ 'test-failure': 'Run tests locally and fix failing assertions',
146
+ 'missing-file': 'Check if a required file was deleted or not committed',
147
+ 'permissions': 'Check file permissions and access rights',
148
+ 'timeout': 'Investigate slow operations or increase timeout',
149
+ 'oom': 'Check for memory leaks or reduce batch size',
150
+ 'dependency': 'Run npm install and check for version conflicts',
151
+ 'lint': 'Run linter locally and fix violations',
152
+ 'build': 'Check build configuration and dependencies',
153
+ 'container': 'Check Dockerfile and container configuration',
154
+ 'unknown': 'Review full CI logs for error details',
155
+ };
156
+ return actions[classification.type] || actions.unknown;
157
+ }
158
+
159
+ /**
160
+ * Full CI triage: detect CI, fetch status, classify failures, map to files.
161
+ * @param {string} [cwd]
162
+ * @returns {object}
163
+ */
164
+ export function fullTriage(cwd) {
165
+ const ci = detectCI(cwd);
166
+ if (!ci.primary) return { available: false, reason: 'no-ci-detected' };
167
+
168
+ const status = getCIStatus(cwd);
169
+ if (!status.available) return { available: false, reason: 'gh-cli-unavailable' };
170
+ if (!status.hasFailures) return { available: true, healthy: true, message: 'All CI runs passing' };
171
+
172
+ const failedRuns = status.runs.filter(r => r.conclusion === 'failure');
173
+ const triages = failedRuns.slice(0, 3).map(r => triageFailure(r.id, cwd));
174
+
175
+ return {
176
+ available: true,
177
+ healthy: false,
178
+ failedRuns: failedRuns.length,
179
+ triages,
180
+ topIssue: triages[0]?.classification || null,
181
+ };
182
+ }
183
+
184
+ // ─── CLI (direct invocation) ──────────────────────────────────────────────────
185
+
186
+ const isMain = process.argv[1]?.endsWith('ci-triage.mjs');
187
+ if (isMain) {
188
+ const cwd = process.argv[2] || process.cwd();
189
+ const result = fullTriage(cwd);
190
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
191
+ }
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ // continuity.mjs — Session continuity for dual-brain.
3
+ // Generates handoff receipts so the next session can pick up seamlessly
4
+ // when a session hits context limits, crashes, or is manually ended.
5
+ //
6
+ // Exports: generateHandoff, saveHandoff, getLatestHandoff, getHandoffAge,
7
+ // buildCompactionSurvivalKit, buildResumeBrief, pruneHandoffs,
8
+ // extractRoutingPatterns
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ // ─── Session chaining ─────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Generate a compact handoff object from current session state.
17
+ * Designed to fit in ~500 tokens when serialized.
18
+ *
19
+ * @param {object} sessionState
20
+ * @param {string} [sessionState.taskDescription]
21
+ * @param {string[]} [sessionState.filesChanged]
22
+ * @param {string[]} [sessionState.testsRun]
23
+ * @param {object[]} [sessionState.decisions] Most recent routing decisions
24
+ * @param {string[]} [sessionState.unresolved] Open questions / blockers
25
+ * @param {object} [sessionState.routingHistory]
26
+ * @param {string} [sessionState.routingHistory.lastProvider]
27
+ * @param {string} [sessionState.routingHistory.lastModel]
28
+ * @param {string[]} [sessionState.routingHistory.failedProviders]
29
+ * @param {string[]} [sessionState.activePreferences]
30
+ * @param {string} [sessionState.resumeHint] e.g. "continue implementing auth refactor"
31
+ * @returns {object}
32
+ */
33
+ export function generateHandoff(sessionState) {
34
+ return {
35
+ version: 1,
36
+ timestamp: new Date().toISOString(),
37
+ task: sessionState.taskDescription || null,
38
+ progress: {
39
+ filesChanged: (sessionState.filesChanged || []).slice(0, 20),
40
+ testsRun: sessionState.testsRun || [],
41
+ decisions: (sessionState.decisions || []).slice(0, 5), // most recent routing decisions
42
+ },
43
+ unresolved: (sessionState.unresolved || []).slice(0, 5),
44
+ routing: {
45
+ lastProvider: sessionState.routingHistory?.lastProvider || null,
46
+ lastModel: sessionState.routingHistory?.lastModel || null,
47
+ failedProviders: sessionState.routingHistory?.failedProviders || [],
48
+ },
49
+ preferences: sessionState.activePreferences || [],
50
+ resumeHint: sessionState.resumeHint || null,
51
+ };
52
+ }
53
+
54
+ // ─── Handoff persistence ──────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Persist a handoff object to .dual-brain/handoffs/.
58
+ * @param {object} handoff Result of generateHandoff()
59
+ * @param {string} [cwd] Project root (defaults to process.cwd())
60
+ * @returns {string} Absolute path of the written file
61
+ */
62
+ export function saveHandoff(handoff, cwd) {
63
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
64
+ mkdirSync(dir, { recursive: true });
65
+ const filename = `handoff-${Date.now()}.json`;
66
+ writeFileSync(join(dir, filename), JSON.stringify(handoff, null, 2));
67
+ return join(dir, filename);
68
+ }
69
+
70
+ /**
71
+ * Load the most recent handoff from .dual-brain/handoffs/.
72
+ * Returns null when no handoffs exist or all are unreadable.
73
+ * @param {string} [cwd]
74
+ * @returns {object|null}
75
+ */
76
+ export function getLatestHandoff(cwd) {
77
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
78
+ if (!existsSync(dir)) return null;
79
+ const files = readdirSync(dir)
80
+ .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
81
+ .sort()
82
+ .reverse();
83
+ if (files.length === 0) return null;
84
+ try {
85
+ return JSON.parse(readFileSync(join(dir, files[0]), 'utf8'));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Return the age of a handoff in hours.
93
+ * Returns Infinity when the handoff has no timestamp.
94
+ * @param {object|null} handoff
95
+ * @returns {number} Hours since handoff was generated
96
+ */
97
+ export function getHandoffAge(handoff) {
98
+ if (!handoff?.timestamp) return Infinity;
99
+ return (Date.now() - Date.parse(handoff.timestamp)) / 3600000;
100
+ }
101
+
102
+ // ─── Smart compaction ─────────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Build a compaction-safe summary string to inject before context compression.
106
+ * The content must survive being summarised by a compression pass, so keep it
107
+ * terse, high-signal, and easy to re-state.
108
+ *
109
+ * @param {object} state
110
+ * @param {string} [state.activeTask]
111
+ * @param {string[]} [state.routingRules]
112
+ * @param {string[]} [state.criticalDecisions]
113
+ * @param {string[]} [state.filesInProgress]
114
+ * @param {string[]} [state.preferences]
115
+ * @param {string[]} [state.warnings]
116
+ * @returns {string}
117
+ */
118
+ export function buildCompactionSurvivalKit(state) {
119
+ const lines = [];
120
+ lines.push('[DUAL-BRAIN CONTINUITY]');
121
+
122
+ if (state.activeTask) {
123
+ lines.push(`TASK: ${state.activeTask}`);
124
+ }
125
+ if (state.routingRules?.length) {
126
+ lines.push(`ROUTING: ${state.routingRules.join('; ')}`);
127
+ }
128
+ if (state.criticalDecisions?.length) {
129
+ lines.push(`DECISIONS: ${state.criticalDecisions.join('; ')}`);
130
+ }
131
+ if (state.filesInProgress?.length) {
132
+ lines.push(`FILES: ${state.filesInProgress.join(', ')}`);
133
+ }
134
+ if (state.preferences?.length) {
135
+ lines.push(`PREFS: ${state.preferences.join('; ')}`);
136
+ }
137
+ if (state.warnings?.length) {
138
+ lines.push(`WARNINGS: ${state.warnings.join('; ')}`);
139
+ }
140
+
141
+ lines.push('[/DUAL-BRAIN CONTINUITY]');
142
+ return lines.join('\n');
143
+ }
144
+
145
+ // ─── Resume brief builder ─────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Check for a recent handoff and build a resume context string for a new session.
149
+ * Returns null when no usable handoff exists (missing, too stale, or unreadable).
150
+ *
151
+ * @param {string} [cwd]
152
+ * @returns {string|null}
153
+ */
154
+ export function buildResumeBrief(cwd) {
155
+ const handoff = getLatestHandoff(cwd);
156
+ if (!handoff) return null;
157
+
158
+ const ageHours = getHandoffAge(handoff);
159
+ if (ageHours > 48) return null; // too stale to be useful
160
+
161
+ const lines = [];
162
+ const ageLabel =
163
+ ageHours < 1
164
+ ? 'just now'
165
+ : ageHours < 24
166
+ ? `${Math.round(ageHours)}h ago`
167
+ : `${Math.round(ageHours / 24)}d ago`;
168
+
169
+ lines.push(`Resuming from previous session (${ageLabel}):`);
170
+
171
+ if (handoff.task) lines.push(` Task: ${handoff.task}`);
172
+ if (handoff.resumeHint) lines.push(` Next: ${handoff.resumeHint}`);
173
+ if (handoff.progress?.filesChanged?.length) {
174
+ const shown = handoff.progress.filesChanged.slice(0, 5);
175
+ const extra = handoff.progress.filesChanged.length > 5
176
+ ? ` (+${handoff.progress.filesChanged.length - 5} more)`
177
+ : '';
178
+ lines.push(` Changed: ${shown.join(', ')}${extra}`);
179
+ }
180
+ if (handoff.unresolved?.length) {
181
+ lines.push(` Unresolved: ${handoff.unresolved.join('; ')}`);
182
+ }
183
+ if (handoff.routing?.failedProviders?.length) {
184
+ lines.push(` Note: ${handoff.routing.failedProviders.join(', ')} failed last session`);
185
+ }
186
+
187
+ return lines.join('\n');
188
+ }
189
+
190
+ // ─── Handoff cleanup ──────────────────────────────────────────────────────────
191
+
192
+ /**
193
+ * Remove old handoff files, keeping only the most recent `keep` entries.
194
+ * @param {string} [cwd]
195
+ * @param {number} [keep=10]
196
+ * @returns {number} Count of files pruned
197
+ */
198
+ export function pruneHandoffs(cwd, keep = 10) {
199
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
200
+ if (!existsSync(dir)) return 0;
201
+ const files = readdirSync(dir)
202
+ .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
203
+ .sort()
204
+ .reverse();
205
+ let pruned = 0;
206
+ for (const f of files.slice(keep)) {
207
+ try {
208
+ unlinkSync(join(dir, f));
209
+ pruned++;
210
+ } catch {
211
+ // Skip files that can't be removed — best-effort
212
+ }
213
+ }
214
+ return pruned;
215
+ }
216
+
217
+ // ─── Cross-session learning ───────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Extract routing patterns from handoff history to inform provider/model selection.
221
+ *
222
+ * @param {string} [cwd]
223
+ * @returns {{
224
+ * patterns: Array<{ type: string, value: string, count: number }>,
225
+ * confidence: number,
226
+ * sampleSize: number
227
+ * }}
228
+ */
229
+ export function extractRoutingPatterns(cwd) {
230
+ const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
231
+ if (!existsSync(dir)) return { patterns: [], confidence: 0, sampleSize: 0 };
232
+
233
+ const files = readdirSync(dir)
234
+ .filter(f => f.startsWith('handoff-') && f.endsWith('.json'))
235
+ .sort()
236
+ .reverse()
237
+ .slice(0, 20);
238
+
239
+ const handoffs = files
240
+ .map(f => {
241
+ try {
242
+ return JSON.parse(readFileSync(join(dir, f), 'utf8'));
243
+ } catch {
244
+ return null;
245
+ }
246
+ })
247
+ .filter(Boolean);
248
+
249
+ // Count provider/model usage patterns
250
+ const providerCounts = {};
251
+ const modelCounts = {};
252
+ const failureCounts = {};
253
+
254
+ for (const h of handoffs) {
255
+ if (h.routing?.lastProvider) {
256
+ providerCounts[h.routing.lastProvider] = (providerCounts[h.routing.lastProvider] || 0) + 1;
257
+ }
258
+ if (h.routing?.lastModel) {
259
+ modelCounts[h.routing.lastModel] = (modelCounts[h.routing.lastModel] || 0) + 1;
260
+ }
261
+ for (const fp of (h.routing?.failedProviders || [])) {
262
+ failureCounts[fp] = (failureCounts[fp] || 0) + 1;
263
+ }
264
+ }
265
+
266
+ const patterns = [];
267
+
268
+ // Most used provider
269
+ const topProvider = Object.entries(providerCounts).sort((a, b) => b[1] - a[1])[0];
270
+ if (topProvider) {
271
+ patterns.push({ type: 'preferred_provider', value: topProvider[0], count: topProvider[1] });
272
+ }
273
+
274
+ // Most used model
275
+ const topModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0];
276
+ if (topModel) {
277
+ patterns.push({ type: 'preferred_model', value: topModel[0], count: topModel[1] });
278
+ }
279
+
280
+ // Frequently failing provider (threshold: 3+ failures)
281
+ const topFailure = Object.entries(failureCounts).sort((a, b) => b[1] - a[1])[0];
282
+ if (topFailure && topFailure[1] >= 3) {
283
+ patterns.push({ type: 'unreliable_provider', value: topFailure[0], count: topFailure[1] });
284
+ }
285
+
286
+ return {
287
+ patterns,
288
+ confidence: Math.min(1, handoffs.length / 10),
289
+ sampleSize: handoffs.length,
290
+ };
291
+ }
package/src/detect.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  import { readFileSync } from 'fs';
6
6
  import { resolve, dirname } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
+ import { execSync } from 'child_process';
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
 
@@ -365,6 +366,39 @@ function detectSuggestedPlugins(prompt) {
365
366
  return [...matched];
366
367
  }
367
368
 
369
+ // ─── CI risk check ────────────────────────────────────────────────────────────
370
+
371
+ /**
372
+ * Lightweight CI risk check: returns true if the current branch has a recent
373
+ * CI failure, indicating the task may touch already-broken code.
374
+ * Intentionally best-effort — any error returns false (never blocks detection).
375
+ * @param {string} [cwd]
376
+ * @returns {{ hasCIFailure: boolean, failedBranch: string|null }}
377
+ */
378
+ function checkCIRisk(cwd) {
379
+ try {
380
+ const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
381
+ cwd, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
382
+ }).trim();
383
+
384
+ const json = execSync(
385
+ 'gh run list --limit 5 --json conclusion,headBranch 2>/dev/null',
386
+ { cwd, encoding: 'utf8', timeout: 8000 }
387
+ );
388
+ const runs = JSON.parse(json);
389
+ const branchFailure = runs.find(
390
+ r => r.conclusion === 'failure' && r.headBranch === currentBranch
391
+ );
392
+
393
+ return {
394
+ hasCIFailure: Boolean(branchFailure),
395
+ failedBranch: branchFailure ? currentBranch : null,
396
+ };
397
+ } catch {
398
+ return { hasCIFailure: false, failedBranch: null };
399
+ }
400
+ }
401
+
368
402
  /** Main detection function. Input: { prompt, files?, priorFailures?, sessionContext? } */
369
403
  function detectTask(input) {
370
404
  const { prompt = '', files = [], sessionContext = null } = input;
@@ -455,6 +489,9 @@ function detectTask(input) {
455
489
  // 10. Suggested Codex plugins (keyword-based, static map — no I/O)
456
490
  const suggestedPlugins = detectSuggestedPlugins(prompt);
457
491
 
492
+ // 11. CI risk — check if current branch has failing CI runs (best-effort, never throws)
493
+ const ciRiskResult = checkCIRisk(input.cwd || process.cwd());
494
+
458
495
  return {
459
496
  intent,
460
497
  risk,
@@ -470,6 +507,7 @@ function detectTask(input) {
470
507
  reasoningDepth,
471
508
  reasoningSignals,
472
509
  suggestedPlugins,
510
+ ciRisk: ciRiskResult,
473
511
  ...(repeatedFailure && { repeatedFailure: true }),
474
512
  };
475
513
  }
package/src/dispatch.mjs CHANGED
@@ -675,6 +675,7 @@ async function dispatch(input = {}) {
675
675
  // ── Resume brief injection ───────────────────────────────────────────────────
676
676
  // Inject the last session's receipt as context when no situationBrief is already set.
677
677
  // This closes the receipt → brief → next session loop automatically.
678
+ // Falls back to continuity.mjs handoffs when receipt.mjs returns nothing.
678
679
  if (!input.situationBrief) {
679
680
  try {
680
681
  const { buildResumeBrief } = await import('./receipt.mjs');
@@ -683,6 +684,17 @@ async function dispatch(input = {}) {
683
684
  input = { ...input, situationBrief: brief };
684
685
  }
685
686
  } catch { /* non-blocking */ }
687
+
688
+ // Continuity fallback: check handoff from continuity.mjs if still no brief
689
+ if (!input.situationBrief) {
690
+ try {
691
+ const { buildResumeBrief: buildHandoffBrief } = await import('./continuity.mjs');
692
+ const handoffBrief = buildHandoffBrief(cwd);
693
+ if (handoffBrief) {
694
+ input = { ...input, situationBrief: handoffBrief };
695
+ }
696
+ } catch { /* non-blocking */ }
697
+ }
686
698
  }
687
699
  // ── End resume brief injection ───────────────────────────────────────────────
688
700
 
@@ -848,6 +860,23 @@ async function dispatch(input = {}) {
848
860
  }
849
861
  }
850
862
 
863
+ // ── Worktree isolation decision ──────────────────────────────────────────────
864
+ // Compute whether this dispatch should run in an isolated worktree based on
865
+ // risk level, file-edit volume, and security/auth signals in the prompt.
866
+ const SECURITY_PATTERN = /\b(auth|secret|token|credential|password|key|oauth|jwt|session|permission|role|acl)\b/i;
867
+ const decisionRisk = (decision.risk ?? 'low').toLowerCase();
868
+ const decisionFilesEst = decision.filesEstimate ?? 0;
869
+ const riskIsElevated = decisionRisk === 'medium' || decisionRisk === 'high' || decisionRisk === 'critical';
870
+ const manyFiles = decisionFilesEst >= 3;
871
+ const hasSecurity = SECURITY_PATTERN.test(prompt);
872
+ const useWorktree = input.useWorktree ?? (riskIsElevated || manyFiles || hasSecurity);
873
+
874
+ // Propagate useWorktree onto effectiveDecision so callers can inspect it
875
+ if (useWorktree) {
876
+ effectiveDecision = { ...effectiveDecision, useWorktree: true };
877
+ }
878
+ // ── End worktree isolation decision ─────────────────────────────────────────
879
+
851
880
  // ── Native Claude Code dispatch ──────────────────────────────────────────────
852
881
  // When running inside Claude Code AND the provider is claude, execute via the
853
882
  // claude CLI directly (foreground subprocess) so results are captured and returned.
@@ -856,7 +885,7 @@ async function dispatch(input = {}) {
856
885
  const nativeDescriptor = buildNativeDispatch(
857
886
  effectiveDecision,
858
887
  prompt,
859
- { worktree: input.worktree, maxTurns: input.maxTurns },
888
+ { worktree: useWorktree, maxTurns: input.maxTurns },
860
889
  );
861
890
 
862
891
  const command = buildCommand(effectiveDecision, prompt, files, cwd);
@@ -955,6 +984,23 @@ async function dispatch(input = {}) {
955
984
  success,
956
985
  });
957
986
 
987
+ // ── Auto-review annotation ────────────────────────────────────────────────
988
+ // When execution changed files at medium+ risk, stamp result with a pending
989
+ // review note. The opposite provider from the one that did the work reviews
990
+ // it (true dual-brain). Non-blocking — does not delay the return value.
991
+ let autoReview;
992
+ if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
993
+ try {
994
+ const reviewProvider = currentProvider === 'claude' ? 'openai' : 'claude';
995
+ autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
996
+ } catch {
997
+ autoReview = { triggered: false, reason: 'review-dispatch-failed' };
998
+ }
999
+ } else {
1000
+ autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
1001
+ }
1002
+ // ── End auto-review annotation ────────────────────────────────────────────
1003
+
958
1004
  return {
959
1005
  status: success ? 'completed' : 'failed',
960
1006
  type: 'native-agent',
@@ -967,6 +1013,8 @@ async function dispatch(input = {}) {
967
1013
  summary,
968
1014
  durationMs,
969
1015
  usage,
1016
+ worktreeUsed: useWorktree,
1017
+ autoReview,
970
1018
  error: success ? null : errorText.slice(0, 200),
971
1019
  };
972
1020
  }
@@ -1054,16 +1102,35 @@ async function dispatch(input = {}) {
1054
1102
  success,
1055
1103
  });
1056
1104
 
1105
+ // ── Auto-review annotation ──────────────────────────────────────────────────
1106
+ // When execution changed files at medium+ risk, stamp result with a pending
1107
+ // review note. The opposite provider from the one that did the work reviews
1108
+ // it (true dual-brain). Non-blocking — does not delay the return value.
1109
+ let autoReview;
1110
+ if (success && (decision.risk === 'medium' || decision.risk === 'high' || decision.risk === 'critical')) {
1111
+ try {
1112
+ const reviewProvider = subProvider === 'claude' ? 'openai' : 'claude';
1113
+ autoReview = { triggered: true, provider: reviewProvider, status: 'pending' };
1114
+ } catch {
1115
+ autoReview = { triggered: false, reason: 'review-dispatch-failed' };
1116
+ }
1117
+ } else {
1118
+ autoReview = { triggered: false, reason: success ? 'low-risk' : 'dispatch-failed' };
1119
+ }
1120
+ // ── End auto-review annotation ──────────────────────────────────────────────
1121
+
1057
1122
  return {
1058
- status: success ? 'completed' : 'failed',
1059
- provider: subProvider,
1060
- model: subModel,
1061
- specialist: specialist ?? 'generic',
1062
- command: subCommand,
1123
+ status: success ? 'completed' : 'failed',
1124
+ provider: subProvider,
1125
+ model: subModel,
1126
+ specialist: specialist ?? 'generic',
1127
+ command: subCommand,
1063
1128
  exitCode,
1064
1129
  summary,
1065
1130
  durationMs,
1066
1131
  usage,
1132
+ worktreeUsed: useWorktree,
1133
+ autoReview,
1067
1134
  error: success ? null : errorText.slice(0, 200),
1068
1135
  };
1069
1136
  }