dual-brain 0.2.3 → 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.
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
  }
package/src/health.mjs CHANGED
@@ -314,6 +314,41 @@ export function resetHealth(cwd) {
314
314
  saveRaw({ states: {}, session: null }, cwd);
315
315
  }
316
316
 
317
+ // ─── Network timeout guard ────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Ping a provider URL with a bounded timeout so slow networks don't hang the CLI.
321
+ *
322
+ * Uses AbortController to enforce the deadline. On timeout or network error the
323
+ * caller receives { ok: false, status: 'timeout' } rather than hanging forever.
324
+ *
325
+ * @param {string} url
326
+ * @param {{ timeoutMs?: number, headers?: Record<string,string> }} [opts]
327
+ * @returns {Promise<{ ok: boolean, status: 'ok'|'timeout'|'error', detail?: string }>}
328
+ */
329
+ export async function pingProvider(url, opts = {}) {
330
+ const timeoutMs = opts.timeoutMs ?? 5000;
331
+ const controller = new AbortController();
332
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
333
+ try {
334
+ const res = await fetch(url, {
335
+ method: 'HEAD',
336
+ signal: controller.signal,
337
+ headers: opts.headers ?? {},
338
+ });
339
+ clearTimeout(timer);
340
+ return { ok: res.ok, status: 'ok', detail: String(res.status) };
341
+ } catch (err) {
342
+ clearTimeout(timer);
343
+ const isTimeout = err?.name === 'AbortError';
344
+ return {
345
+ ok: false,
346
+ status: isTimeout ? 'timeout' : 'error',
347
+ detail: isTimeout ? `Provider health: unknown (timeout after ${timeoutMs}ms)` : String(err?.message),
348
+ };
349
+ }
350
+ }
351
+
317
352
  // ─── Remaining cooldown helper (used by status display) ──────────────────────
318
353
 
319
354
  /**
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
  }
@@ -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,
@@ -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
+ }