@weldr/runr 0.4.0 → 0.7.2

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +127 -1
  2. package/README.md +124 -165
  3. package/dist/audit/classifier.js +331 -0
  4. package/dist/cli.js +570 -300
  5. package/dist/commands/audit.js +259 -0
  6. package/dist/commands/bundle.js +180 -0
  7. package/dist/commands/continue.js +276 -0
  8. package/dist/commands/doctor.js +430 -45
  9. package/dist/commands/hooks.js +352 -0
  10. package/dist/commands/init.js +368 -8
  11. package/dist/commands/intervene.js +109 -0
  12. package/dist/commands/meta.js +245 -0
  13. package/dist/commands/mode.js +157 -0
  14. package/dist/commands/orchestrate.js +29 -0
  15. package/dist/commands/packs.js +47 -0
  16. package/dist/commands/preflight.js +8 -5
  17. package/dist/commands/resume.js +421 -3
  18. package/dist/commands/run.js +63 -4
  19. package/dist/commands/status.js +47 -0
  20. package/dist/commands/submit.js +374 -0
  21. package/dist/config/schema.js +61 -1
  22. package/dist/diagnosis/analyzer.js +86 -1
  23. package/dist/diagnosis/formatter.js +3 -0
  24. package/dist/diagnosis/index.js +1 -0
  25. package/dist/diagnosis/stop-explainer.js +267 -0
  26. package/dist/diagnostics/stop-explainer.js +267 -0
  27. package/dist/guards/checkpoint.js +119 -0
  28. package/dist/journal/builder.js +36 -3
  29. package/dist/journal/renderer.js +19 -0
  30. package/dist/orchestrator/artifacts.js +17 -2
  31. package/dist/orchestrator/receipt.js +304 -0
  32. package/dist/output/stop-footer.js +185 -0
  33. package/dist/packs/actions.js +176 -0
  34. package/dist/packs/loader.js +200 -0
  35. package/dist/packs/renderer.js +46 -0
  36. package/dist/receipt/intervention.js +465 -0
  37. package/dist/receipt/writer.js +296 -0
  38. package/dist/redaction/redactor.js +95 -0
  39. package/dist/repo/context.js +147 -20
  40. package/dist/review/check-parser.js +211 -0
  41. package/dist/store/checkpoint-metadata.js +111 -0
  42. package/dist/store/run-store.js +21 -0
  43. package/dist/supervisor/runner.js +130 -10
  44. package/dist/tasks/task-metadata.js +74 -1
  45. package/dist/ux/brain.js +528 -0
  46. package/dist/ux/render.js +123 -0
  47. package/dist/ux/safe-commands.js +133 -0
  48. package/dist/ux/state.js +193 -0
  49. package/dist/ux/telemetry.js +110 -0
  50. package/package.json +3 -1
  51. package/packs/pr/pack.json +50 -0
  52. package/packs/pr/templates/AGENTS.md.tmpl +120 -0
  53. package/packs/pr/templates/CLAUDE.md.tmpl +101 -0
  54. package/packs/pr/templates/bundle.md.tmpl +27 -0
  55. package/packs/solo/pack.json +82 -0
  56. package/packs/solo/templates/AGENTS.md.tmpl +80 -0
  57. package/packs/solo/templates/CLAUDE.md.tmpl +126 -0
  58. package/packs/solo/templates/bundle.md.tmpl +27 -0
  59. package/packs/solo/templates/claude-cmd-bundle.md.tmpl +40 -0
  60. package/packs/solo/templates/claude-cmd-resume.md.tmpl +43 -0
  61. package/packs/solo/templates/claude-cmd-submit.md.tmpl +51 -0
  62. package/packs/solo/templates/claude-skill.md.tmpl +96 -0
  63. package/packs/trunk/pack.json +50 -0
  64. package/packs/trunk/templates/AGENTS.md.tmpl +87 -0
  65. package/packs/trunk/templates/CLAUDE.md.tmpl +126 -0
  66. package/packs/trunk/templates/bundle.md.tmpl +27 -0
@@ -337,7 +337,8 @@ async function extractChanges(runDir, base_sha, head_sha, warnings) {
337
337
  insertions: null,
338
338
  deletions: null,
339
339
  top_files: null,
340
- diff_stat: null
340
+ diff_stat: null,
341
+ ignored_changes: null
341
342
  };
342
343
  }
343
344
  try {
@@ -373,6 +374,8 @@ async function extractChanges(runDir, base_sha, head_sha, warnings) {
373
374
  cwd: repoPath,
374
375
  encoding: 'utf-8'
375
376
  });
377
+ // Extract ignored changes from timeline
378
+ const ignoredChanges = await extractIgnoredChanges(runDir, warnings);
376
379
  return {
377
380
  base_sha,
378
381
  head_sha,
@@ -380,7 +383,8 @@ async function extractChanges(runDir, base_sha, head_sha, warnings) {
380
383
  insertions: totalInsertions,
381
384
  deletions: totalDeletions,
382
385
  top_files: topFiles.length > 0 ? topFiles : null,
383
- diff_stat: diffStat.trim()
386
+ diff_stat: diffStat.trim(),
387
+ ignored_changes: ignoredChanges
384
388
  };
385
389
  }
386
390
  catch (err) {
@@ -392,10 +396,39 @@ async function extractChanges(runDir, base_sha, head_sha, warnings) {
392
396
  insertions: null,
393
397
  deletions: null,
394
398
  top_files: null,
395
- diff_stat: null
399
+ diff_stat: null,
400
+ ignored_changes: null
396
401
  };
397
402
  }
398
403
  }
404
+ /**
405
+ * Extract ignored changes summary from timeline events
406
+ */
407
+ async function extractIgnoredChanges(runDir, warnings) {
408
+ try {
409
+ const timelinePath = path.join(runDir, 'timeline.jsonl');
410
+ if (!fs.existsSync(timelinePath)) {
411
+ return null;
412
+ }
413
+ const events = await readTimelineEvents(timelinePath);
414
+ const ignoredEvents = events.filter(e => e.type === 'ignored_changes');
415
+ if (ignoredEvents.length === 0) {
416
+ return null;
417
+ }
418
+ // Take the last ignored_changes event (most recent)
419
+ const lastEvent = ignoredEvents[ignoredEvents.length - 1];
420
+ const payload = lastEvent.payload;
421
+ return {
422
+ count: payload.ignored_count,
423
+ sample: payload.ignored_sample,
424
+ ignore_check_status: payload.ignore_check_status
425
+ };
426
+ }
427
+ catch (err) {
428
+ warnings.push(`Failed to extract ignored changes: ${err.message}`);
429
+ return null;
430
+ }
431
+ }
399
432
  /**
400
433
  * Extract next action from stop.json or derive
401
434
  */
@@ -163,6 +163,25 @@ function renderChanges(journal) {
163
163
  lines.push(journal.changes.diff_stat);
164
164
  lines.push('```');
165
165
  }
166
+ // Render ignored changes (tool pollution)
167
+ if (journal.changes.ignored_changes) {
168
+ const { count, sample, ignore_check_status } = journal.changes.ignored_changes;
169
+ if (ignore_check_status === 'failed') {
170
+ lines.push('\n> **Warning:** Git ignore-check failed; guard ran in strict mode.');
171
+ }
172
+ if (count > 0) {
173
+ lines.push(`\n**Ignored Tool Noise:** ${count} file${count === 1 ? '' : 's'} (filtered from guard checks)`);
174
+ if (sample.length > 0) {
175
+ lines.push('\nSample (first 20):');
176
+ for (const file of sample.slice(0, 20)) {
177
+ lines.push(`- \`${file}\``);
178
+ }
179
+ if (count > sample.length) {
180
+ lines.push(`\n_...and ${count - sample.length} more_`);
181
+ }
182
+ }
183
+ }
184
+ }
166
185
  return lines.join('\n');
167
186
  }
168
187
  function renderNextAction(nextAction) {
@@ -353,10 +353,12 @@ export function generateOrchestrationMarkdown(state, summary) {
353
353
  * Order is critical:
354
354
  * 1. summary.json
355
355
  * 2. orchestration.md
356
- * 3. complete.json OR stop.json (LAST - signals terminal)
356
+ * 3. receipt.json and receipt.md
357
+ * 4. complete.json OR stop.json (LAST - signals terminal)
357
358
  */
358
359
  export function writeTerminalArtifacts(state, repoPath) {
359
360
  const handoffsDir = getHandoffsDir(repoPath, state.orchestrator_id);
361
+ const orchDir = getOrchestrationDir(repoPath, state.orchestrator_id);
360
362
  fs.mkdirSync(handoffsDir, { recursive: true });
361
363
  const isComplete = state.status === 'complete';
362
364
  // 1. Write summary.json
@@ -365,7 +367,20 @@ export function writeTerminalArtifacts(state, repoPath) {
365
367
  // 2. Write orchestration.md
366
368
  const markdown = generateOrchestrationMarkdown(state, summary);
367
369
  fs.writeFileSync(path.join(handoffsDir, 'orchestration.md'), markdown);
368
- // 3. Write complete.json OR stop.json (LAST)
370
+ // 3. Write receipt artifacts (manager dashboard)
371
+ try {
372
+ // Dynamic import to avoid circular dependency
373
+ import('./receipt.js').then(({ buildReceipt, writeReceipt }) => {
374
+ const receipt = buildReceipt(state, repoPath);
375
+ writeReceipt(receipt, repoPath);
376
+ }).catch(() => {
377
+ // Ignore receipt generation errors - not critical
378
+ });
379
+ }
380
+ catch {
381
+ // Ignore receipt generation errors - not critical
382
+ }
383
+ // 4. Write complete.json OR stop.json (LAST)
369
384
  if (isComplete) {
370
385
  const completeArtifact = buildWaitResult(state, repoPath);
371
386
  fs.writeFileSync(path.join(handoffsDir, 'complete.json'), JSON.stringify(completeArtifact, null, 2));
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Orchestration Receipt Generator.
3
+ *
4
+ * Produces "manager dashboard" artifacts that summarize an orchestration:
5
+ * - receipt.json: Machine-readable summary with task outcomes, interventions, and issues
6
+ * - receipt.md: Human-readable markdown summary
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { getOrchestrationDir, findOrchestrationDir } from './artifacts.js';
11
+ import { loadOrchestratorState, findLatestOrchestrationId } from './state-machine.js';
12
+ import { getRunsRoot } from '../store/runs-root.js';
13
+ export const RECEIPT_SCHEMA_VERSION = '1';
14
+ /**
15
+ * Suggested fixes for common stop reasons.
16
+ */
17
+ const STOP_REASON_FIXES = {
18
+ 'review_loop_detected': 'Check reviewer expectations match verifier output',
19
+ 'max_ticks_reached': 'Consider increasing --max-ticks or breaking into smaller tasks',
20
+ 'time_budget_exceeded': 'Consider increasing --time or simplifying the task',
21
+ 'verification_failed': 'Review verification commands and fix failing tests',
22
+ 'user_stop': 'Task was stopped by user request',
23
+ 'collision_detected': 'Ensure tasks have non-overlapping ownership declarations'
24
+ };
25
+ /**
26
+ * Get suggested fix for a stop reason.
27
+ */
28
+ function getSuggestedFix(reason) {
29
+ return STOP_REASON_FIXES[reason] ?? 'Review run logs for details';
30
+ }
31
+ /**
32
+ * Find intervention receipt for a run.
33
+ */
34
+ function findIntervention(repoPath, runId) {
35
+ const runsRoot = getRunsRoot(repoPath);
36
+ const runDir = path.join(runsRoot, runId);
37
+ const interventionsDir = path.join(runDir, 'interventions');
38
+ if (!fs.existsSync(interventionsDir)) {
39
+ return undefined;
40
+ }
41
+ // Find any intervention receipt
42
+ try {
43
+ const entries = fs.readdirSync(interventionsDir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ if (entry.isDirectory()) {
46
+ const receiptPath = path.join(interventionsDir, entry.name, 'intervention-receipt.json');
47
+ if (fs.existsSync(receiptPath)) {
48
+ try {
49
+ const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf-8'));
50
+ return {
51
+ receipt_path: path.relative(repoPath, receiptPath),
52
+ reason: receipt.reason ?? 'unknown'
53
+ };
54
+ }
55
+ catch {
56
+ return {
57
+ receipt_path: path.relative(repoPath, receiptPath),
58
+ reason: 'unknown'
59
+ };
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ catch {
66
+ // Ignore errors
67
+ }
68
+ return undefined;
69
+ }
70
+ /**
71
+ * Read run state to get checkpoint SHA and milestones.
72
+ */
73
+ function getRunDetails(repoPath, runId) {
74
+ const runsRoot = getRunsRoot(repoPath);
75
+ const stateFile = path.join(runsRoot, runId, 'state.json');
76
+ if (!fs.existsSync(stateFile)) {
77
+ return { milestones: 0 };
78
+ }
79
+ try {
80
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
81
+ return {
82
+ checkpointSha: state.checkpoint_commit_sha,
83
+ milestones: state.completed_milestones ?? 0
84
+ };
85
+ }
86
+ catch {
87
+ return { milestones: 0 };
88
+ }
89
+ }
90
+ /**
91
+ * Build receipt task entry from track step.
92
+ */
93
+ function buildReceiptTask(repoPath, step, track) {
94
+ const runId = step.run_id;
95
+ const result = step.result;
96
+ // Determine status
97
+ let status = 'pending';
98
+ if (result) {
99
+ status = result.status === 'complete' ? 'finished' : 'stopped';
100
+ }
101
+ // Get run details
102
+ const details = runId ? getRunDetails(repoPath, runId) : { milestones: 0 };
103
+ // Find intervention
104
+ const intervention = runId ? findIntervention(repoPath, runId) : undefined;
105
+ return {
106
+ task_path: step.task_path,
107
+ run_id: runId,
108
+ status,
109
+ stop_reason: result?.stop_reason,
110
+ milestones_completed: details.milestones,
111
+ checkpoint_sha: details.checkpointSha,
112
+ duration_ms: result?.elapsed_ms ?? 0,
113
+ intervention
114
+ };
115
+ }
116
+ /**
117
+ * Aggregate stop reasons across all tasks.
118
+ */
119
+ function aggregateStopReasons(tasks) {
120
+ const counts = new Map();
121
+ for (const task of tasks) {
122
+ if (task.stop_reason) {
123
+ counts.set(task.stop_reason, (counts.get(task.stop_reason) ?? 0) + 1);
124
+ }
125
+ }
126
+ // Sort by count descending
127
+ return [...counts.entries()]
128
+ .sort((a, b) => b[1] - a[1])
129
+ .map(([reason, count]) => ({
130
+ reason,
131
+ count,
132
+ suggested_fix: getSuggestedFix(reason)
133
+ }));
134
+ }
135
+ /**
136
+ * Build receipt from orchestrator state.
137
+ */
138
+ export function buildReceipt(state, repoPath) {
139
+ const startTime = new Date(state.started_at).getTime();
140
+ const endTime = state.ended_at ? new Date(state.ended_at).getTime() : Date.now();
141
+ // Build task list
142
+ const tasks = [];
143
+ for (const track of state.tracks) {
144
+ for (const step of track.steps) {
145
+ tasks.push(buildReceiptTask(repoPath, step, track));
146
+ }
147
+ }
148
+ // Count outcomes
149
+ const tasksCompleted = tasks.filter(t => t.status === 'finished').length;
150
+ const tasksStopped = tasks.filter(t => t.status === 'stopped').length;
151
+ const tasksPending = tasks.filter(t => t.status === 'pending').length;
152
+ const interventionsCount = tasks.filter(t => t.intervention).length;
153
+ const totalCheckpoints = tasks.filter(t => t.checkpoint_sha).length;
154
+ // Aggregate stop reasons
155
+ const topStopReasons = aggregateStopReasons(tasks);
156
+ return {
157
+ schema_version: RECEIPT_SCHEMA_VERSION,
158
+ orchestration_id: state.orchestrator_id,
159
+ started_at: state.started_at,
160
+ completed_at: state.ended_at,
161
+ duration_ms: endTime - startTime,
162
+ summary: {
163
+ tasks_total: tasks.length,
164
+ tasks_completed: tasksCompleted,
165
+ tasks_stopped: tasksStopped,
166
+ tasks_pending: tasksPending,
167
+ interventions_count: interventionsCount,
168
+ total_checkpoints: totalCheckpoints
169
+ },
170
+ tasks,
171
+ top_stop_reasons: topStopReasons
172
+ };
173
+ }
174
+ /**
175
+ * Format duration in human-readable form.
176
+ */
177
+ function formatDuration(ms) {
178
+ const seconds = Math.floor(ms / 1000);
179
+ const minutes = Math.floor(seconds / 60);
180
+ const hours = Math.floor(minutes / 60);
181
+ if (hours > 0) {
182
+ return `${hours}h ${minutes % 60}m`;
183
+ }
184
+ if (minutes > 0) {
185
+ return `${minutes}m ${seconds % 60}s`;
186
+ }
187
+ return `${seconds}s`;
188
+ }
189
+ /**
190
+ * Generate markdown receipt from JSON receipt.
191
+ */
192
+ export function generateReceiptMarkdown(receipt) {
193
+ const lines = [];
194
+ // Header
195
+ lines.push(`# Orchestration Receipt: ${receipt.orchestration_id}`);
196
+ lines.push('');
197
+ // Summary table
198
+ lines.push('## Summary');
199
+ lines.push('');
200
+ lines.push('| Metric | Value |');
201
+ lines.push('|--------|-------|');
202
+ lines.push(`| Duration | ${formatDuration(receipt.duration_ms)} |`);
203
+ lines.push(`| Tasks | ${receipt.summary.tasks_completed}/${receipt.summary.tasks_total} completed |`);
204
+ lines.push(`| Checkpoints | ${receipt.summary.total_checkpoints} |`);
205
+ lines.push(`| Interventions | ${receipt.summary.interventions_count} |`);
206
+ lines.push('');
207
+ // Tasks section
208
+ lines.push('## Tasks');
209
+ lines.push('');
210
+ for (const task of receipt.tasks) {
211
+ const statusIcon = task.status === 'finished' ? '✓' : task.status === 'stopped' ? '⚠' : '○';
212
+ const taskName = path.basename(task.task_path);
213
+ lines.push(`### ${statusIcon} ${taskName}`);
214
+ if (task.run_id) {
215
+ lines.push(`- Run: ${task.run_id}`);
216
+ }
217
+ lines.push(`- Status: ${task.status}${task.stop_reason ? ` (${task.stop_reason})` : ''}`);
218
+ if (task.checkpoint_sha) {
219
+ lines.push(`- Checkpoint: ${task.checkpoint_sha.slice(0, 7)}`);
220
+ }
221
+ if (task.intervention) {
222
+ lines.push(`- Intervention: ${task.intervention.reason}`);
223
+ }
224
+ lines.push('');
225
+ }
226
+ // Top issues
227
+ if (receipt.top_stop_reasons.length > 0) {
228
+ lines.push('## Top Issues');
229
+ lines.push('');
230
+ for (let i = 0; i < receipt.top_stop_reasons.length; i++) {
231
+ const entry = receipt.top_stop_reasons[i];
232
+ lines.push(`${i + 1}. **${entry.reason}** (${entry.count} occurrence${entry.count > 1 ? 's' : ''})`);
233
+ lines.push(` - Suggested: ${entry.suggested_fix}`);
234
+ }
235
+ lines.push('');
236
+ }
237
+ // Next steps
238
+ if (receipt.summary.tasks_stopped > 0) {
239
+ lines.push('## Next Steps');
240
+ lines.push('');
241
+ const stoppedTasks = receipt.tasks.filter(t => t.status === 'stopped');
242
+ for (const task of stoppedTasks) {
243
+ lines.push(`- Review stopped task: ${path.basename(task.task_path)}`);
244
+ }
245
+ lines.push('');
246
+ }
247
+ return lines.join('\n');
248
+ }
249
+ /**
250
+ * Write receipt artifacts to orchestration directory.
251
+ */
252
+ export function writeReceipt(receipt, repoPath) {
253
+ const orchDir = getOrchestrationDir(repoPath, receipt.orchestration_id);
254
+ fs.mkdirSync(orchDir, { recursive: true });
255
+ const jsonPath = path.join(orchDir, 'receipt.json');
256
+ const mdPath = path.join(orchDir, 'receipt.md');
257
+ fs.writeFileSync(jsonPath, JSON.stringify(receipt, null, 2));
258
+ fs.writeFileSync(mdPath, generateReceiptMarkdown(receipt));
259
+ return { json: jsonPath, md: mdPath };
260
+ }
261
+ /**
262
+ * Load existing receipt if available.
263
+ */
264
+ export function loadReceipt(repoPath, orchestratorId) {
265
+ const orchDir = findOrchestrationDir(repoPath, orchestratorId);
266
+ if (!orchDir) {
267
+ return null;
268
+ }
269
+ const receiptPath = path.join(orchDir, 'receipt.json');
270
+ if (!fs.existsSync(receiptPath)) {
271
+ return null;
272
+ }
273
+ try {
274
+ return JSON.parse(fs.readFileSync(receiptPath, 'utf-8'));
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ /**
281
+ * Generate or load receipt for an orchestration.
282
+ */
283
+ export function getReceipt(repoPath, orchestratorId) {
284
+ // Resolve "latest"
285
+ let resolvedId = orchestratorId;
286
+ if (orchestratorId === 'latest') {
287
+ const latest = findLatestOrchestrationId(repoPath);
288
+ if (!latest) {
289
+ return null;
290
+ }
291
+ resolvedId = latest;
292
+ }
293
+ // Try to load existing receipt
294
+ const existing = loadReceipt(repoPath, resolvedId);
295
+ if (existing) {
296
+ return existing;
297
+ }
298
+ // Generate from state
299
+ const state = loadOrchestratorState(resolvedId, repoPath);
300
+ if (!state) {
301
+ return null;
302
+ }
303
+ return buildReceipt(state, repoPath);
304
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Stop Footer - Consistent "Next Steps" block for stopped runs.
3
+ *
4
+ * Shows exactly 3 actions derived from the brain module for consistency
5
+ * across front door, continue command, and stop footer.
6
+ *
7
+ * For review_loop_detected, also shows:
8
+ * - Reviewer requested items
9
+ * - Commands to satisfy
10
+ * - Suggested intervention command
11
+ */
12
+ const SEPARATOR = '─'.repeat(50);
13
+ /**
14
+ * Get context line based on stop reason.
15
+ */
16
+ function getContextLine(ctx) {
17
+ switch (ctx.stopReason) {
18
+ case 'review_loop_detected':
19
+ if (ctx.lastError) {
20
+ // Extract first 2 items from error message if it contains a list
21
+ const match = ctx.lastError.match(/(?:Unmet|Failed|Missing):\s*(.+)/i);
22
+ if (match) {
23
+ const items = match[1].split(/[,;]/).slice(0, 2).map(s => s.trim());
24
+ return `Unmet: ${items.join(', ')}`;
25
+ }
26
+ return `Unmet: ${ctx.lastError.slice(0, 60)}...`;
27
+ }
28
+ return 'Unmet: review requirements not satisfied';
29
+ case 'verification_failed':
30
+ if (ctx.lastError) {
31
+ const cmdMatch = ctx.lastError.match(/command.*failed|failed.*command/i);
32
+ if (cmdMatch) {
33
+ return `Failed: ${ctx.lastError.slice(0, 60)}`;
34
+ }
35
+ return `Failed: verification check`;
36
+ }
37
+ return 'Failed: verification check';
38
+ case 'scope_violation':
39
+ if (ctx.lastError) {
40
+ // Extract file paths from error
41
+ const files = ctx.lastError.match(/[\w./\-_]+\.\w+/g);
42
+ if (files && files.length > 0) {
43
+ return `Files: ${files.slice(0, 2).join(', ')}`;
44
+ }
45
+ }
46
+ return 'Files: scope boundary exceeded';
47
+ case 'stalled_timeout':
48
+ case 'worker_call_timeout':
49
+ return `Stalled at: ${ctx.phase || 'unknown phase'}`;
50
+ case 'guard_fail':
51
+ case 'preflight_failed':
52
+ if (ctx.lastError) {
53
+ const guardMatch = ctx.lastError.match(/guard.*failed|failed.*guard/i);
54
+ if (guardMatch) {
55
+ return `Guard: ${ctx.lastError.slice(0, 50)}`;
56
+ }
57
+ }
58
+ return 'Guard: preflight check failed';
59
+ default:
60
+ // No context line for other reasons
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Build next steps commands.
66
+ */
67
+ export function buildNextSteps(runId, stopReason) {
68
+ return {
69
+ resume: `runr resume ${runId}`,
70
+ intervene: `runr intervene ${runId} --reason ${stopReason || 'manual'} --note "..."`,
71
+ audit: `runr runs audit --run ${runId}`
72
+ };
73
+ }
74
+ /**
75
+ * Format stop footer for console output.
76
+ * If brainActions are provided, uses them for consistent UX across all entry points.
77
+ * Otherwise falls back to default 3 commands (resume, intervene, audit).
78
+ */
79
+ export function formatStopFooter(ctx, brainActions) {
80
+ const lines = [];
81
+ lines.push(SEPARATOR);
82
+ // Header with optional round info
83
+ if (ctx.stopReason === 'review_loop_detected' && ctx.reviewRound && ctx.maxReviewRounds) {
84
+ lines.push(`STOPPED: ${ctx.stopReason} (round ${ctx.reviewRound}/${ctx.maxReviewRounds})`);
85
+ }
86
+ else {
87
+ lines.push(`STOPPED: ${ctx.stopReason}`);
88
+ }
89
+ lines.push('');
90
+ // Last checkpoint line
91
+ if (ctx.checkpointSha) {
92
+ lines.push(`Last checkpoint: ${ctx.checkpointSha.slice(0, 7)} (milestone ${ctx.milestoneIndex + 1}/${ctx.milestonesTotal})`);
93
+ }
94
+ else {
95
+ lines.push(`No checkpoint (milestone ${ctx.milestoneIndex + 1}/${ctx.milestonesTotal})`);
96
+ }
97
+ // Enhanced output for review_loop_detected
98
+ if (ctx.stopReason === 'review_loop_detected') {
99
+ // Show reviewer requests if available
100
+ if (ctx.reviewerRequests && ctx.reviewerRequests.length > 0) {
101
+ lines.push('');
102
+ lines.push('Reviewer requested:');
103
+ ctx.reviewerRequests.slice(0, 3).forEach((req, i) => {
104
+ lines.push(` ${i + 1}. ${req}`);
105
+ });
106
+ if (ctx.reviewerRequests.length > 3) {
107
+ lines.push(` ... and ${ctx.reviewerRequests.length - 3} more`);
108
+ }
109
+ }
110
+ // Show commands to satisfy if available
111
+ if (ctx.commandsToSatisfy && ctx.commandsToSatisfy.length > 0) {
112
+ lines.push('');
113
+ lines.push('Commands to satisfy:');
114
+ for (const cmd of ctx.commandsToSatisfy) {
115
+ lines.push(` ${cmd}`);
116
+ }
117
+ }
118
+ // Show suggested intervention
119
+ lines.push('');
120
+ lines.push('Suggested intervention:');
121
+ if (ctx.commandsToSatisfy && ctx.commandsToSatisfy.length > 0) {
122
+ const cmdArgs = ctx.commandsToSatisfy.map(c => `--cmd "${c}"`).join(' ');
123
+ lines.push(` runr intervene ${ctx.runId} --reason review_loop \\`);
124
+ lines.push(` --note "Fixed review requests" ${cmdArgs}`);
125
+ }
126
+ else {
127
+ lines.push(` runr intervene ${ctx.runId} --reason review_loop \\`);
128
+ lines.push(` --note "Fixed review requests" --cmd "npm run build""`);
129
+ }
130
+ }
131
+ else {
132
+ // Context line based on stop reason (for non-review_loop cases)
133
+ const contextLine = getContextLine(ctx);
134
+ if (contextLine) {
135
+ lines.push(contextLine);
136
+ }
137
+ }
138
+ lines.push('');
139
+ lines.push('Next steps:');
140
+ if (brainActions && brainActions.length >= 3) {
141
+ // Use brain-computed actions for consistency across UX
142
+ for (const action of brainActions.slice(0, 3)) {
143
+ lines.push(` ${action.command}`);
144
+ }
145
+ }
146
+ else {
147
+ // Fallback to default commands
148
+ const steps = buildNextSteps(ctx.runId, ctx.stopReason);
149
+ lines.push(` ${steps.resume}`);
150
+ lines.push(` ${steps.intervene}`);
151
+ lines.push(` ${steps.audit}`);
152
+ }
153
+ lines.push(SEPARATOR);
154
+ return lines.join('\n');
155
+ }
156
+ /**
157
+ * Build stop context from run state.
158
+ * Extended version accepts optional review loop data.
159
+ */
160
+ export function buildStopContext(state, reviewLoopData) {
161
+ return {
162
+ runId: state.run_id,
163
+ stopReason: state.stop_reason || 'unknown',
164
+ checkpointSha: state.checkpoint_commit_sha,
165
+ milestoneIndex: state.milestone_index,
166
+ milestonesTotal: state.milestones.length,
167
+ lastError: state.last_error,
168
+ phase: state.phase,
169
+ // Extended review loop fields
170
+ reviewRound: reviewLoopData?.reviewRound ?? state.review_rounds,
171
+ maxReviewRounds: reviewLoopData?.maxReviewRounds,
172
+ reviewerRequests: reviewLoopData?.reviewerRequests,
173
+ commandsToSatisfy: reviewLoopData?.commandsToSatisfy
174
+ };
175
+ }
176
+ /**
177
+ * Print stop footer to console.
178
+ * If reviewLoopData is provided, includes enhanced diagnostics.
179
+ * If brainActions is provided, uses them for consistent UX (from brain module).
180
+ */
181
+ export function printStopFooter(state, reviewLoopData, brainActions) {
182
+ const ctx = buildStopContext(state, reviewLoopData);
183
+ console.log('');
184
+ console.log(formatStopFooter(ctx, brainActions));
185
+ }