@weldr/runr 0.4.0 → 0.7.3

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 +166 -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
@@ -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
  }
@@ -18,7 +18,9 @@ import { runVerification } from '../verification/engine.js';
18
18
  import { stopRun, updatePhase, prepareForResume } from './state-machine.js';
19
19
  import { buildJournal } from '../journal/builder.js';
20
20
  import { renderJournal } from '../journal/renderer.js';
21
+ import { writeReceipt, extractBaseSha, deriveTerminalState, printRunReceipt } from '../receipt/writer.js';
21
22
  import { getActiveRuns, checkFileCollisions, formatFileCollisionError } from './collision.js';
23
+ import { parseReviewFeedback } from '../review/check-parser.js';
22
24
  import { validateNoChangesEvidence, formatEvidenceErrors } from './evidence-gate.js';
23
25
  import { normalizeOwnsPatterns, toPosixPath } from '../ownership/normalize.js';
24
26
  export function checkOwnership(changedFiles, ownedPaths, envAllowlist) {
@@ -607,6 +609,49 @@ async function runSupervisorOnce(options) {
607
609
  // Never crash on journal generation failure
608
610
  console.warn(`Warning: Failed to generate journal: ${err.message}`);
609
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
+ }
610
655
  }
611
656
  }
612
657
  /**
@@ -902,6 +947,16 @@ async function handleImplement(state, options) {
902
947
  return stopWithError(state, options, 'implement_blocked', implementer.handoff_memo);
903
948
  }
904
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
+ }
905
960
  const scopeCheck = checkScope(changedFiles, state.scope_lock.allowlist, state.scope_lock.denylist);
906
961
  const lockfileCheck = checkLockfiles(changedFiles, options.config.scope.lockfiles, options.allowDeps);
907
962
  if (!scopeCheck.ok || !lockfileCheck.ok) {
@@ -1221,6 +1276,10 @@ async function handleReview(state, options) {
1221
1276
  const exceededRounds = currentRounds > maxRounds;
1222
1277
  if (sameFingerprint || exceededRounds) {
1223
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;
1224
1283
  options.runStore.appendEvent({
1225
1284
  type: 'review_loop_detected',
1226
1285
  source: 'supervisor',
@@ -1229,24 +1288,55 @@ async function handleReview(state, options) {
1229
1288
  review_rounds: currentRounds,
1230
1289
  max_review_rounds: maxRounds,
1231
1290
  same_fingerprint: sameFingerprint,
1232
- 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
1233
1295
  }
1234
1296
  });
1235
- // Write review digest for debugging
1297
+ // Write enhanced review digest for debugging
1236
1298
  const digestLines = [
1237
1299
  '# Review Digest',
1238
1300
  '',
1239
1301
  `**Milestone:** ${state.milestone_index + 1} of ${state.milestones.length}`,
1240
- `**Review Rounds:** ${currentRounds}`,
1302
+ `**Review Rounds:** ${currentRounds} (max: ${maxRounds})`,
1241
1303
  `**Stop Reason:** ${reason}`,
1242
1304
  '',
1243
- '## Last Requested Changes',
1305
+ '## Reviewer Requested Changes',
1244
1306
  '',
1245
1307
  ...review.changes.map((change, i) => `${i + 1}. ${change}`),
1246
- '',
1247
- '## Status',
1248
- `- **Verdict:** ${review.status}`
1308
+ ''
1249
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}`);
1250
1340
  options.runStore.writeMemo('review_digest.md', digestLines.join('\n'));
1251
1341
  const errorMsg = sameFingerprint
1252
1342
  ? `Identical review feedback detected after ${currentRounds} rounds. Manual intervention required.`
@@ -1278,14 +1368,43 @@ async function handleCheckpoint(state, options) {
1278
1368
  const status = await git(['status', '--porcelain'], options.repoPath);
1279
1369
  if (status.stdout.trim().length > 0) {
1280
1370
  await git(['add', '-A'], options.repoPath);
1281
- const message = `chore(agent): checkpoint milestone ${state.milestone_index + 1}`;
1371
+ const message = `chore(runr): checkpoint ${state.run_id} milestone ${state.milestone_index}`;
1282
1372
  await git(['commit', '-m', message], options.repoPath);
1283
1373
  }
1284
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
+ }
1285
1404
  const nextIndex = state.milestone_index + 1;
1286
1405
  const updated = {
1287
1406
  ...state,
1288
- checkpoint_commit_sha: shaResult.stdout.trim(),
1407
+ checkpoint_commit_sha: sha,
1289
1408
  milestone_index: nextIndex,
1290
1409
  milestone_retries: 0,
1291
1410
  last_verify_failure: undefined,
@@ -1297,7 +1416,8 @@ async function handleCheckpoint(state, options) {
1297
1416
  source: 'supervisor',
1298
1417
  payload: {
1299
1418
  commit: updated.checkpoint_commit_sha,
1300
- milestone_index: state.milestone_index
1419
+ milestone_index: state.milestone_index,
1420
+ sidecar_written: sidecarWritten
1301
1421
  }
1302
1422
  });
1303
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
  }