@weldr/runr 0.3.1 → 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 (81) hide show
  1. package/CHANGELOG.md +150 -1
  2. package/README.md +124 -111
  3. package/dist/audit/classifier.js +331 -0
  4. package/dist/cli.js +593 -282
  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/journal.js +167 -0
  13. package/dist/commands/meta.js +245 -0
  14. package/dist/commands/mode.js +157 -0
  15. package/dist/commands/orchestrate.js +29 -0
  16. package/dist/commands/packs.js +47 -0
  17. package/dist/commands/preflight.js +8 -5
  18. package/dist/commands/resume.js +421 -3
  19. package/dist/commands/run.js +63 -4
  20. package/dist/commands/status.js +47 -0
  21. package/dist/commands/submit.js +374 -0
  22. package/dist/config/schema.js +61 -1
  23. package/dist/diagnosis/analyzer.js +86 -1
  24. package/dist/diagnosis/formatter.js +3 -0
  25. package/dist/diagnosis/index.js +1 -0
  26. package/dist/diagnosis/stop-explainer.js +267 -0
  27. package/dist/diagnostics/stop-explainer.js +267 -0
  28. package/dist/guards/checkpoint.js +119 -0
  29. package/dist/journal/builder.js +497 -0
  30. package/dist/journal/redactor.js +68 -0
  31. package/dist/journal/renderer.js +220 -0
  32. package/dist/journal/types.js +7 -0
  33. package/dist/orchestrator/artifacts.js +17 -2
  34. package/dist/orchestrator/receipt.js +304 -0
  35. package/dist/output/stop-footer.js +185 -0
  36. package/dist/packs/actions.js +176 -0
  37. package/dist/packs/loader.js +200 -0
  38. package/dist/packs/renderer.js +46 -0
  39. package/dist/receipt/intervention.js +465 -0
  40. package/dist/receipt/writer.js +296 -0
  41. package/dist/redaction/redactor.js +95 -0
  42. package/dist/repo/context.js +147 -20
  43. package/dist/review/check-parser.js +211 -0
  44. package/dist/store/checkpoint-metadata.js +111 -0
  45. package/dist/store/run-store.js +21 -0
  46. package/dist/supervisor/runner.js +161 -10
  47. package/dist/tasks/task-metadata.js +74 -1
  48. package/dist/ux/brain.js +528 -0
  49. package/dist/ux/render.js +123 -0
  50. package/dist/ux/safe-commands.js +133 -0
  51. package/dist/ux/state.js +193 -0
  52. package/dist/ux/telemetry.js +110 -0
  53. package/package.json +5 -1
  54. package/packs/pr/pack.json +50 -0
  55. package/packs/pr/templates/AGENTS.md.tmpl +120 -0
  56. package/packs/pr/templates/CLAUDE.md.tmpl +101 -0
  57. package/packs/pr/templates/bundle.md.tmpl +27 -0
  58. package/packs/solo/pack.json +82 -0
  59. package/packs/solo/templates/AGENTS.md.tmpl +80 -0
  60. package/packs/solo/templates/CLAUDE.md.tmpl +126 -0
  61. package/packs/solo/templates/bundle.md.tmpl +27 -0
  62. package/packs/solo/templates/claude-cmd-bundle.md.tmpl +40 -0
  63. package/packs/solo/templates/claude-cmd-resume.md.tmpl +43 -0
  64. package/packs/solo/templates/claude-cmd-submit.md.tmpl +51 -0
  65. package/packs/solo/templates/claude-skill.md.tmpl +96 -0
  66. package/packs/trunk/pack.json +50 -0
  67. package/packs/trunk/templates/AGENTS.md.tmpl +87 -0
  68. package/packs/trunk/templates/CLAUDE.md.tmpl +126 -0
  69. package/packs/trunk/templates/bundle.md.tmpl +27 -0
  70. package/dist/commands/__tests__/report.test.js +0 -202
  71. package/dist/config/__tests__/presets.test.js +0 -104
  72. package/dist/context/__tests__/artifact.test.js +0 -130
  73. package/dist/context/__tests__/pack.test.js +0 -191
  74. package/dist/env/__tests__/fingerprint.test.js +0 -116
  75. package/dist/orchestrator/__tests__/policy.test.js +0 -185
  76. package/dist/orchestrator/__tests__/schema-version.test.js +0 -65
  77. package/dist/supervisor/__tests__/evidence-gate.test.js +0 -111
  78. package/dist/supervisor/__tests__/ownership.test.js +0 -103
  79. package/dist/supervisor/__tests__/state-machine.test.js +0 -290
  80. package/dist/workers/__tests__/claude.test.js +0 -88
  81. package/dist/workers/__tests__/codex.test.js +0 -81
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Review Check Parser - Extracts actionable requests from review feedback.
3
+ *
4
+ * Parses review responses to identify:
5
+ * - Bullet points and numbered lists
6
+ * - "Please fix/add/update" patterns
7
+ * - Done check names marked as incomplete
8
+ */
9
+ /**
10
+ * Command mapping patterns - keywords to verification commands.
11
+ */
12
+ const COMMAND_MAPPINGS = [
13
+ {
14
+ keywords: ['type error', 'typescript error', 'ts error', 'typecheck', 'tsc'],
15
+ command: 'npm run typecheck',
16
+ category: 'type_errors'
17
+ },
18
+ {
19
+ keywords: ['test', 'tests', 'unit test', 'testing', 'spec'],
20
+ command: 'npm test',
21
+ category: 'tests'
22
+ },
23
+ {
24
+ keywords: ['lint', 'linting', 'eslint', 'style'],
25
+ command: 'npm run lint',
26
+ category: 'lint'
27
+ },
28
+ {
29
+ keywords: ['build', 'compile', 'bundle'],
30
+ command: 'npm run build',
31
+ category: 'build'
32
+ },
33
+ {
34
+ keywords: ['coverage', 'test coverage', 'code coverage'],
35
+ command: 'npm test -- --coverage',
36
+ category: 'coverage'
37
+ }
38
+ ];
39
+ /**
40
+ * Request extraction patterns.
41
+ */
42
+ const REQUEST_PATTERNS = [
43
+ // Numbered lists: "1. Fix the issue"
44
+ /^\s*\d+[.)]\s*(.+)/gm,
45
+ // Bullet points: "- Fix the issue", "* Fix the issue"
46
+ /^\s*[-*•]\s*(.+)/gm,
47
+ // "Please" patterns: "Please fix...", "Please add..."
48
+ /please\s+(fix|add|update|remove|change|include|ensure|verify|check|run)\s+(.+?)(?:\.|$)/gi,
49
+ // "Need to" patterns: "Need to fix...", "Need to add..."
50
+ /(?:need|needs|should|must|have)\s+to\s+(fix|add|update|remove|change|include|run)\s+(.+?)(?:\.|$)/gi,
51
+ // "Fix the X" patterns
52
+ /fix\s+(?:the\s+)?(.+?)\s+(?:error|issue|problem|bug)/gi,
53
+ // "Add X" patterns
54
+ /add\s+(?:the\s+)?(.+?)\s+(?:test|output|evidence|check)/gi,
55
+ // "Missing X" patterns
56
+ /missing\s+(.+?)(?:\.|,|$)/gi
57
+ ];
58
+ /**
59
+ * Extract bullet points and numbered lists from text.
60
+ */
61
+ export function extractListItems(text) {
62
+ const items = [];
63
+ // Numbered lists
64
+ const numberedMatches = text.matchAll(/^\s*\d+[.)]\s*(.+)/gm);
65
+ for (const match of numberedMatches) {
66
+ items.push(match[1].trim());
67
+ }
68
+ // Bullet points
69
+ const bulletMatches = text.matchAll(/^\s*[-*•]\s*(.+)/gm);
70
+ for (const match of bulletMatches) {
71
+ items.push(match[1].trim());
72
+ }
73
+ return items;
74
+ }
75
+ /**
76
+ * Extract "please fix/add" style requests.
77
+ */
78
+ export function extractActionRequests(text) {
79
+ const requests = [];
80
+ // "Please" patterns
81
+ const pleaseMatches = text.matchAll(/please\s+(fix|add|update|remove|change|include|ensure|verify|check|run)\s+(.+?)(?:\.|,|$)/gi);
82
+ for (const match of pleaseMatches) {
83
+ requests.push(`${match[1]} ${match[2]}`.trim());
84
+ }
85
+ // "Need to" patterns
86
+ const needMatches = text.matchAll(/(?:need|needs|should|must|have)\s+to\s+(fix|add|update|remove|change|include|run)\s+(.+?)(?:\.|,|$)/gi);
87
+ for (const match of needMatches) {
88
+ requests.push(`${match[1]} ${match[2]}`.trim());
89
+ }
90
+ return requests;
91
+ }
92
+ /**
93
+ * Map a request to a suggested verification command.
94
+ */
95
+ export function mapToCommand(request) {
96
+ const lower = request.toLowerCase();
97
+ for (const mapping of COMMAND_MAPPINGS) {
98
+ if (mapping.keywords.some(kw => lower.includes(kw))) {
99
+ return { command: mapping.command, category: mapping.category };
100
+ }
101
+ }
102
+ return {};
103
+ }
104
+ /**
105
+ * Parse review feedback and extract actionable requests.
106
+ */
107
+ export function parseReviewFeedback(reviewText) {
108
+ const requests = [];
109
+ const commandsSet = new Set();
110
+ // Check if approved (no further action needed)
111
+ const lowerText = reviewText.toLowerCase();
112
+ const isApproved = lowerText.includes('approved') ||
113
+ lowerText.includes('lgtm') ||
114
+ (lowerText.includes('ready') && lowerText.includes('merge'));
115
+ if (isApproved) {
116
+ return { requests: [], commandsToSatisfy: [], isApproved: true };
117
+ }
118
+ // Extract list items
119
+ const listItems = extractListItems(reviewText);
120
+ for (const item of listItems) {
121
+ const { command, category } = mapToCommand(item);
122
+ requests.push({ text: item, suggestedCommand: command, category });
123
+ if (command)
124
+ commandsSet.add(command);
125
+ }
126
+ // Extract action requests if no list items found
127
+ if (requests.length === 0) {
128
+ const actionRequests = extractActionRequests(reviewText);
129
+ for (const req of actionRequests) {
130
+ const { command, category } = mapToCommand(req);
131
+ requests.push({ text: req, suggestedCommand: command, category });
132
+ if (command)
133
+ commandsSet.add(command);
134
+ }
135
+ }
136
+ // Look for specific patterns not in lists
137
+ const errorPatterns = [
138
+ { pattern: /(\d+)\s+(?:type\s+)?error/i, category: 'type_errors' },
139
+ { pattern: /(\d+)\s+(?:test|spec)s?\s+fail/i, category: 'tests' },
140
+ { pattern: /coverage\s+(?:is\s+)?(?:only\s+)?(\d+)%/i, category: 'coverage' },
141
+ { pattern: /(\d+)%\s+coverage/i, category: 'coverage' },
142
+ { pattern: /lint\s+(?:error|fail)/i, category: 'lint' },
143
+ { pattern: /build\s+(?:error|fail)/i, category: 'build' }
144
+ ];
145
+ for (const { pattern, category } of errorPatterns) {
146
+ const match = reviewText.match(pattern);
147
+ if (match) {
148
+ const mapping = COMMAND_MAPPINGS.find(m => m.category === category);
149
+ if (mapping && !commandsSet.has(mapping.command)) {
150
+ // Only add if not already in requests
151
+ const hasCategory = requests.some(r => r.category === category);
152
+ if (!hasCategory) {
153
+ requests.push({
154
+ text: match[0],
155
+ suggestedCommand: mapping.command,
156
+ category
157
+ });
158
+ commandsSet.add(mapping.command);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return {
164
+ requests,
165
+ commandsToSatisfy: Array.from(commandsSet),
166
+ isApproved: false
167
+ };
168
+ }
169
+ /**
170
+ * Format parsed review for display.
171
+ */
172
+ export function formatReviewRequests(parsed, runId) {
173
+ const lines = [];
174
+ if (parsed.isApproved) {
175
+ lines.push('Review: Approved');
176
+ return lines;
177
+ }
178
+ if (parsed.requests.length > 0) {
179
+ lines.push('Reviewer requested:');
180
+ parsed.requests.slice(0, 3).forEach((req, i) => {
181
+ lines.push(` ${i + 1}. ${req.text}`);
182
+ });
183
+ if (parsed.requests.length > 3) {
184
+ lines.push(` ... and ${parsed.requests.length - 3} more`);
185
+ }
186
+ }
187
+ if (parsed.commandsToSatisfy.length > 0) {
188
+ lines.push('');
189
+ lines.push('Commands to satisfy:');
190
+ for (const cmd of parsed.commandsToSatisfy) {
191
+ lines.push(` ${cmd}`);
192
+ }
193
+ }
194
+ // Build suggested intervention command
195
+ if (parsed.commandsToSatisfy.length > 0) {
196
+ lines.push('');
197
+ lines.push('Suggested intervention:');
198
+ const cmdArgs = parsed.commandsToSatisfy.map(c => `--cmd "${c}"`).join(' ');
199
+ lines.push(` runr intervene ${runId} --reason review_loop \\`);
200
+ lines.push(` --note "Fixed review requests" ${cmdArgs}`);
201
+ }
202
+ return lines;
203
+ }
204
+ /**
205
+ * Extract review feedback from a done_check list.
206
+ */
207
+ export function extractUnmetDoneChecks(doneChecks) {
208
+ return doneChecks
209
+ .filter(check => !check.passed)
210
+ .map(check => check.message || check.name);
211
+ }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Write checkpoint metadata sidecar file.
5
+ * Best-effort: does not throw if write fails.
6
+ */
7
+ export async function writeCheckpointMetadata(options) {
8
+ const { repoPath, sha, runId, milestoneIndex, milestone, tier, verificationCommands } = options;
9
+ const checkpointsDir = path.join(repoPath, '.runr', 'checkpoints');
10
+ await fs.mkdir(checkpointsDir, { recursive: true });
11
+ const metadata = {
12
+ schema_version: 1,
13
+ sha,
14
+ run_id: runId,
15
+ milestone_index: milestoneIndex,
16
+ milestone_title: milestone.goal,
17
+ created_at: new Date().toISOString()
18
+ };
19
+ // Add optional fields only if available
20
+ if (tier !== undefined) {
21
+ metadata.tier = tier;
22
+ }
23
+ if (verificationCommands !== undefined && verificationCommands.length > 0) {
24
+ metadata.verification_commands = verificationCommands;
25
+ }
26
+ const metadataPath = path.join(checkpointsDir, `${sha}.json`);
27
+ const tempPath = `${metadataPath}.tmp`;
28
+ // Atomic write with Windows safety
29
+ await fs.writeFile(tempPath, JSON.stringify(metadata, null, 2), 'utf-8');
30
+ // Windows-safe rename: unlink destination if exists
31
+ try {
32
+ await fs.unlink(metadataPath);
33
+ }
34
+ catch {
35
+ // Ignore if doesn't exist
36
+ }
37
+ await fs.rename(tempPath, metadataPath);
38
+ }
39
+ /**
40
+ * Find the latest checkpoint for a run by scanning sidecar files.
41
+ * Returns null if no valid sidecars found.
42
+ * Selection: highest milestone_index, then latest created_at, then latest mtime.
43
+ */
44
+ export async function findLatestCheckpointBySidecar(repoPath, runId) {
45
+ const checkpointsDir = path.join(repoPath, '.runr', 'checkpoints');
46
+ try {
47
+ await fs.access(checkpointsDir);
48
+ }
49
+ catch {
50
+ return null; // No sidecars yet
51
+ }
52
+ const files = await fs.readdir(checkpointsDir);
53
+ const jsonFiles = files.filter(f => f.endsWith('.json') && f !== 'index.json');
54
+ let latestCheckpoint = null;
55
+ for (const file of jsonFiles) {
56
+ try {
57
+ const filePath = path.join(checkpointsDir, file);
58
+ const content = await fs.readFile(filePath, 'utf-8');
59
+ const metadata = JSON.parse(content);
60
+ // Sanity checks
61
+ if (metadata.schema_version !== 1) {
62
+ continue; // Ignore unknown schema versions
63
+ }
64
+ // Required fields type checks
65
+ if (typeof metadata.sha !== 'string' ||
66
+ typeof metadata.run_id !== 'string' ||
67
+ typeof metadata.milestone_title !== 'string' ||
68
+ typeof metadata.created_at !== 'string') {
69
+ continue; // Missing/invalid required fields
70
+ }
71
+ const expectedSha = file.replace('.json', '');
72
+ if (metadata.sha !== expectedSha) {
73
+ continue; // SHA mismatch = corruption
74
+ }
75
+ if (!Number.isFinite(metadata.milestone_index) || metadata.milestone_index < 0) {
76
+ continue; // Invalid milestone index
77
+ }
78
+ if (metadata.run_id !== runId) {
79
+ continue; // Wrong run
80
+ }
81
+ // Get file mtime as fallback for tie-breaking
82
+ const stats = await fs.stat(filePath);
83
+ const mtime = stats.mtimeMs;
84
+ // Selection: higher milestone_index, then later created_at, then later mtime
85
+ const shouldReplace = latestCheckpoint === null ||
86
+ metadata.milestone_index > latestCheckpoint.milestoneIndex ||
87
+ (metadata.milestone_index === latestCheckpoint.milestoneIndex && (
88
+ // created_at comparison (handles missing/empty)
89
+ (metadata.created_at || '') > (latestCheckpoint.created_at || '') ||
90
+ // If created_at equal/both missing, use mtime fallback
91
+ ((metadata.created_at || '') === (latestCheckpoint.created_at || '') &&
92
+ mtime > latestCheckpoint.mtime)));
93
+ if (shouldReplace) {
94
+ latestCheckpoint = {
95
+ sha: metadata.sha,
96
+ milestoneIndex: metadata.milestone_index,
97
+ created_at: metadata.created_at || '',
98
+ mtime
99
+ };
100
+ }
101
+ }
102
+ catch {
103
+ continue; // Malformed JSON or other read error
104
+ }
105
+ }
106
+ return latestCheckpoint ? {
107
+ sha: latestCheckpoint.sha,
108
+ milestoneIndex: latestCheckpoint.milestoneIndex,
109
+ created_at: latestCheckpoint.created_at
110
+ } : null;
111
+ }
@@ -86,6 +86,27 @@ export class RunStore {
86
86
  getLastEvent() {
87
87
  return this.lastEvent;
88
88
  }
89
+ /**
90
+ * Read all events from the timeline.
91
+ */
92
+ readTimeline() {
93
+ if (!fs.existsSync(this.timelinePath)) {
94
+ return [];
95
+ }
96
+ const content = fs.readFileSync(this.timelinePath, 'utf-8');
97
+ const events = [];
98
+ for (const line of content.split('\n')) {
99
+ if (line.trim()) {
100
+ try {
101
+ events.push(JSON.parse(line));
102
+ }
103
+ catch {
104
+ // Skip malformed lines
105
+ }
106
+ }
107
+ }
108
+ return events;
109
+ }
89
110
  recordWorkerCall(info) {
90
111
  this.lastWorkerCall = info;
91
112
  }
@@ -16,7 +16,11 @@ import { checkLockfiles, checkScope, partitionChangedFiles } from './scope-guard
16
16
  import { commandsForTier, selectTiersWithReasons } from './verification-policy.js';
17
17
  import { runVerification } from '../verification/engine.js';
18
18
  import { stopRun, updatePhase, prepareForResume } from './state-machine.js';
19
+ import { buildJournal } from '../journal/builder.js';
20
+ import { renderJournal } from '../journal/renderer.js';
21
+ import { writeReceipt, extractBaseSha, deriveTerminalState, printRunReceipt } from '../receipt/writer.js';
19
22
  import { getActiveRuns, checkFileCollisions, formatFileCollisionError } from './collision.js';
23
+ import { parseReviewFeedback } from '../review/check-parser.js';
20
24
  import { validateNoChangesEvidence, formatEvidenceErrors } from './evidence-gate.js';
21
25
  import { normalizeOwnsPatterns, toPosixPath } from '../ownership/normalize.js';
22
26
  export function checkOwnership(changedFiles, ownedPaths, envAllowlist) {
@@ -266,6 +270,24 @@ function buildStructuredStopMemo(params) {
266
270
  lines.push('', '## Next Action', '```bash', nextAction, '```', '', '## Tips', tipsByReason[reason] ?? '- Review the timeline.jsonl for detailed event history.');
267
271
  return lines.join('\n');
268
272
  }
273
+ /**
274
+ * Auto-write journal.md when run completes
275
+ */
276
+ async function writeJournalOnRunComplete(runId, repoPath) {
277
+ try {
278
+ const journal = await buildJournal(runId, repoPath);
279
+ const markdown = renderJournal(journal);
280
+ // Get runs root and construct journal path
281
+ const { getRunsRoot } = await import('../store/runs-root.js');
282
+ const runDir = path.join(getRunsRoot(repoPath), runId);
283
+ const journalPath = path.join(runDir, 'journal.md');
284
+ fs.writeFileSync(journalPath, markdown, 'utf-8');
285
+ console.log(`\n✓ Case file generated: runs/${runId}/journal.md`);
286
+ }
287
+ catch (err) {
288
+ throw new Error(`Failed to generate journal: ${err.message}`);
289
+ }
290
+ }
269
291
  /**
270
292
  * Main supervisor entry point with auto-resume support.
271
293
  *
@@ -576,6 +598,60 @@ async function runSupervisorOnce(options) {
576
598
  }
577
599
  finally {
578
600
  clearInterval(watchdog);
601
+ // Auto-write journal.md when run reaches terminal state
602
+ try {
603
+ const finalState = options.runStore.readState();
604
+ if (finalState.phase === 'STOPPED') {
605
+ await writeJournalOnRunComplete(finalState.run_id, options.repoPath);
606
+ }
607
+ }
608
+ catch (err) {
609
+ // Never crash on journal generation failure
610
+ console.warn(`Warning: Failed to generate journal: ${err.message}`);
611
+ }
612
+ // Auto-write receipt artifacts at terminal state and print Run Receipt
613
+ try {
614
+ const finalState = options.runStore.readState();
615
+ if (finalState.phase === 'STOPPED') {
616
+ const baseSha = extractBaseSha(options.runStore.path);
617
+ const terminalState = deriveTerminalState(finalState.stop_reason);
618
+ const verificationTier = finalState.last_verification_evidence?.tiers_run?.[0] ?? null;
619
+ const result = await writeReceipt({
620
+ runStore: options.runStore,
621
+ repoPath: options.repoPath,
622
+ baseSha,
623
+ checkpointSha: finalState.checkpoint_commit_sha ?? null,
624
+ verificationTier,
625
+ terminalState,
626
+ stopReason: finalState.stop_reason,
627
+ runId: finalState.run_id
628
+ });
629
+ // Print Run Receipt to console
630
+ if (result) {
631
+ // Read diffstat for console output
632
+ const diffstatPath = path.join(options.runStore.path, 'diffstat.txt');
633
+ const diffstat = fs.existsSync(diffstatPath)
634
+ ? fs.readFileSync(diffstatPath, 'utf-8')
635
+ : '';
636
+ // Get integration branch from config
637
+ const integrationBranch = options.config?.workflow?.integration_branch ?? 'main';
638
+ printRunReceipt({
639
+ runId: finalState.run_id,
640
+ terminalState,
641
+ stopReason: finalState.stop_reason,
642
+ receipt: result.receipt,
643
+ patchPath: result.patchPath,
644
+ compressed: result.compressed,
645
+ diffstat,
646
+ integrationBranch
647
+ });
648
+ }
649
+ }
650
+ }
651
+ catch (err) {
652
+ // Never crash on receipt generation failure
653
+ console.warn(`Warning: Failed to generate receipt: ${err.message}`);
654
+ }
579
655
  }
580
656
  }
581
657
  /**
@@ -871,6 +947,16 @@ async function handleImplement(state, options) {
871
947
  return stopWithError(state, options, 'implement_blocked', implementer.handoff_memo);
872
948
  }
873
949
  const changedFiles = await listChangedFiles(options.repoPath);
950
+ // Record ignored files for forensics (journal)
951
+ const { getIgnoredChangesSummary } = await import('../repo/context.js');
952
+ const ignoredSummary = await getIgnoredChangesSummary(options.repoPath);
953
+ if (ignoredSummary.ignored_count > 0 || ignoredSummary.ignore_check_status === 'failed') {
954
+ options.runStore.appendEvent({
955
+ type: 'ignored_changes',
956
+ source: 'supervisor',
957
+ payload: ignoredSummary
958
+ });
959
+ }
874
960
  const scopeCheck = checkScope(changedFiles, state.scope_lock.allowlist, state.scope_lock.denylist);
875
961
  const lockfileCheck = checkLockfiles(changedFiles, options.config.scope.lockfiles, options.allowDeps);
876
962
  if (!scopeCheck.ok || !lockfileCheck.ok) {
@@ -1190,6 +1276,10 @@ async function handleReview(state, options) {
1190
1276
  const exceededRounds = currentRounds > maxRounds;
1191
1277
  if (sameFingerprint || exceededRounds) {
1192
1278
  const reason = sameFingerprint ? 'identical_review_feedback' : 'max_review_rounds_exceeded';
1279
+ // Parse review changes to extract actionable commands
1280
+ const parsedReview = parseReviewFeedback(changesText);
1281
+ const reviewerRequests = review.changes.slice(0, 5);
1282
+ const commandsToSatisfy = parsedReview.commandsToSatisfy;
1193
1283
  options.runStore.appendEvent({
1194
1284
  type: 'review_loop_detected',
1195
1285
  source: 'supervisor',
@@ -1198,24 +1288,55 @@ async function handleReview(state, options) {
1198
1288
  review_rounds: currentRounds,
1199
1289
  max_review_rounds: maxRounds,
1200
1290
  same_fingerprint: sameFingerprint,
1201
- last_changes: review.changes.slice(0, 2) // First 2 items for context
1291
+ last_changes: review.changes.slice(0, 2), // First 2 items for context
1292
+ // Enhanced fields for diagnostics
1293
+ reviewer_requests: reviewerRequests,
1294
+ commands_to_satisfy: commandsToSatisfy
1202
1295
  }
1203
1296
  });
1204
- // Write review digest for debugging
1297
+ // Write enhanced review digest for debugging
1205
1298
  const digestLines = [
1206
1299
  '# Review Digest',
1207
1300
  '',
1208
1301
  `**Milestone:** ${state.milestone_index + 1} of ${state.milestones.length}`,
1209
- `**Review Rounds:** ${currentRounds}`,
1302
+ `**Review Rounds:** ${currentRounds} (max: ${maxRounds})`,
1210
1303
  `**Stop Reason:** ${reason}`,
1211
1304
  '',
1212
- '## Last Requested Changes',
1305
+ '## Reviewer Requested Changes',
1213
1306
  '',
1214
1307
  ...review.changes.map((change, i) => `${i + 1}. ${change}`),
1215
- '',
1216
- '## Status',
1217
- `- **Verdict:** ${review.status}`
1308
+ ''
1218
1309
  ];
1310
+ // Add commands to satisfy section if we found any
1311
+ if (commandsToSatisfy.length > 0) {
1312
+ digestLines.push('## Commands to Satisfy');
1313
+ digestLines.push('');
1314
+ digestLines.push('Run these commands to address the requested changes:');
1315
+ digestLines.push('');
1316
+ digestLines.push('```bash');
1317
+ commandsToSatisfy.forEach(cmd => digestLines.push(cmd));
1318
+ digestLines.push('```');
1319
+ digestLines.push('');
1320
+ }
1321
+ // Add suggested intervention
1322
+ digestLines.push('## Suggested Intervention');
1323
+ digestLines.push('');
1324
+ if (commandsToSatisfy.length > 0) {
1325
+ const cmdArgs = commandsToSatisfy.map(c => `--cmd "${c}"`).join(' ');
1326
+ digestLines.push('```bash');
1327
+ digestLines.push(`runr intervene ${state.run_id} --reason review_loop \\`);
1328
+ digestLines.push(` --note "Fixed review requests" ${cmdArgs}`);
1329
+ digestLines.push('```');
1330
+ }
1331
+ else {
1332
+ digestLines.push('```bash');
1333
+ digestLines.push(`runr intervene ${state.run_id} --reason review_loop \\`);
1334
+ digestLines.push(` --note "Fixed review requests" --cmd "npm run build"`);
1335
+ digestLines.push('```');
1336
+ }
1337
+ digestLines.push('');
1338
+ digestLines.push('## Status');
1339
+ digestLines.push(`- **Verdict:** ${review.status}`);
1219
1340
  options.runStore.writeMemo('review_digest.md', digestLines.join('\n'));
1220
1341
  const errorMsg = sameFingerprint
1221
1342
  ? `Identical review feedback detected after ${currentRounds} rounds. Manual intervention required.`
@@ -1247,14 +1368,43 @@ async function handleCheckpoint(state, options) {
1247
1368
  const status = await git(['status', '--porcelain'], options.repoPath);
1248
1369
  if (status.stdout.trim().length > 0) {
1249
1370
  await git(['add', '-A'], options.repoPath);
1250
- const message = `chore(agent): checkpoint milestone ${state.milestone_index + 1}`;
1371
+ const message = `chore(runr): checkpoint ${state.run_id} milestone ${state.milestone_index}`;
1251
1372
  await git(['commit', '-m', message], options.repoPath);
1252
1373
  }
1253
1374
  const shaResult = await git(['rev-parse', 'HEAD'], options.repoPath);
1375
+ const sha = shaResult.stdout.trim();
1376
+ // Write checkpoint metadata sidecar (best-effort)
1377
+ let sidecarWritten = false;
1378
+ try {
1379
+ const { writeCheckpointMetadata } = await import('../store/checkpoint-metadata.js');
1380
+ await writeCheckpointMetadata({
1381
+ repoPath: options.repoPath,
1382
+ sha,
1383
+ runId: state.run_id,
1384
+ milestoneIndex: state.milestone_index,
1385
+ milestone: state.milestones[state.milestone_index],
1386
+ // Optional fields from last_verification_evidence (safe access)
1387
+ tier: state.last_verification_evidence?.tiers_run?.[0],
1388
+ verificationCommands: state.last_verification_evidence?.commands_run?.map(c => c.command) ?? undefined
1389
+ });
1390
+ sidecarWritten = true;
1391
+ }
1392
+ catch (error) {
1393
+ // Best-effort: don't fail run if sidecar write fails
1394
+ options.runStore.appendEvent({
1395
+ type: 'checkpoint_sidecar_write_failed',
1396
+ source: 'supervisor',
1397
+ payload: {
1398
+ sha,
1399
+ path: path.join(options.repoPath, '.runr', 'checkpoints', `${sha}.json`),
1400
+ error: String(error)
1401
+ }
1402
+ });
1403
+ }
1254
1404
  const nextIndex = state.milestone_index + 1;
1255
1405
  const updated = {
1256
1406
  ...state,
1257
- checkpoint_commit_sha: shaResult.stdout.trim(),
1407
+ checkpoint_commit_sha: sha,
1258
1408
  milestone_index: nextIndex,
1259
1409
  milestone_retries: 0,
1260
1410
  last_verify_failure: undefined,
@@ -1266,7 +1416,8 @@ async function handleCheckpoint(state, options) {
1266
1416
  source: 'supervisor',
1267
1417
  payload: {
1268
1418
  commit: updated.checkpoint_commit_sha,
1269
- milestone_index: state.milestone_index
1419
+ milestone_index: state.milestone_index,
1420
+ sidecar_written: sidecarWritten
1270
1421
  }
1271
1422
  });
1272
1423
  if (nextIndex >= updated.milestones.length) {
@@ -40,6 +40,75 @@ function coerceOwns(value, taskPath) {
40
40
  }
41
41
  throw new Error(`Invalid owns entry in ${taskPath}: must be string or string[]`);
42
42
  }
43
+ /**
44
+ * Parse allowlist_add from frontmatter or body.
45
+ * Accepts both frontmatter field and markdown section.
46
+ */
47
+ function parseAllowlistAdd(frontmatter, body) {
48
+ // Check frontmatter first
49
+ if (frontmatter?.allowlist_add) {
50
+ const value = frontmatter.allowlist_add;
51
+ if (Array.isArray(value)) {
52
+ return value.filter((item) => typeof item === 'string');
53
+ }
54
+ if (typeof value === 'string') {
55
+ return [value];
56
+ }
57
+ }
58
+ // Check for Scope section in markdown body (YAML-like format)
59
+ // Format:
60
+ // ## Scope
61
+ // allowlist_add:
62
+ // - pattern1
63
+ // - pattern2
64
+ const scopeMatch = body.match(/##\s*Scope\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
65
+ if (scopeMatch) {
66
+ const scopeContent = scopeMatch[1];
67
+ const allowlistMatch = scopeContent.match(/allowlist_add:\s*\n((?:\s*-\s*.+\n?)+)/);
68
+ if (allowlistMatch) {
69
+ const items = allowlistMatch[1].match(/-\s*(.+)/g);
70
+ if (items) {
71
+ return items.map(item => item.replace(/^-\s*/, '').trim());
72
+ }
73
+ }
74
+ }
75
+ return [];
76
+ }
77
+ /**
78
+ * Parse verification tier from frontmatter or body.
79
+ * Enforces minimum tier0.
80
+ */
81
+ function parseVerificationTier(frontmatter, body) {
82
+ let tier = null;
83
+ // Check frontmatter first
84
+ if (frontmatter?.verification && typeof frontmatter.verification === 'object') {
85
+ const verification = frontmatter.verification;
86
+ if (verification.tier) {
87
+ tier = String(verification.tier);
88
+ }
89
+ }
90
+ else if (frontmatter?.tier) {
91
+ tier = String(frontmatter.tier);
92
+ }
93
+ // Check for Verification section in markdown body
94
+ if (!tier) {
95
+ const verifyMatch = body.match(/##\s*Verification\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
96
+ if (verifyMatch) {
97
+ const tierMatch = verifyMatch[1].match(/tier:\s*(tier[012])/i);
98
+ if (tierMatch) {
99
+ tier = tierMatch[1].toLowerCase();
100
+ }
101
+ }
102
+ }
103
+ // Normalize and validate
104
+ if (tier) {
105
+ const normalized = tier.toLowerCase();
106
+ if (normalized === 'tier0' || normalized === 'tier1' || normalized === 'tier2') {
107
+ return normalized;
108
+ }
109
+ }
110
+ return null; // Use config default
111
+ }
43
112
  export function loadTaskMetadata(taskPath) {
44
113
  const raw = fs.readFileSync(taskPath, 'utf-8');
45
114
  const { frontmatterText, body } = splitFrontmatter(raw);
@@ -62,11 +131,15 @@ export function loadTaskMetadata(taskPath) {
62
131
  ownsRaw = coerceOwns(frontmatter.owns, taskPath);
63
132
  }
64
133
  const ownsNormalized = normalizeOwnsPatterns(ownsRaw);
134
+ const allowlistAdd = parseAllowlistAdd(frontmatter, body);
135
+ const verificationTier = parseVerificationTier(frontmatter, body);
65
136
  return {
66
137
  raw,
67
138
  body,
68
139
  owns_raw: ownsRaw,
69
140
  owns_normalized: ownsNormalized,
70
- frontmatter
141
+ frontmatter,
142
+ allowlist_add: allowlistAdd,
143
+ verification_tier: verificationTier
71
144
  };
72
145
  }