@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,374 @@
1
+ import { execa } from 'execa';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { RunStore } from '../store/run-store.js';
5
+ import { loadConfig, resolveConfigPath } from '../config/load.js';
6
+ import { clearActiveState } from './hooks.js';
7
+ /**
8
+ * Check if git object exists locally.
9
+ */
10
+ async function objectExists(repoPath, sha) {
11
+ try {
12
+ await execa('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: repoPath });
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /**
20
+ * Check if branch exists.
21
+ */
22
+ async function branchExists(repoPath, branch) {
23
+ try {
24
+ await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
25
+ cwd: repoPath
26
+ });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Check if working tree is clean.
35
+ */
36
+ async function isWorkingTreeClean(repoPath) {
37
+ const result = await execa('git', ['status', '--porcelain'], { cwd: repoPath });
38
+ return result.stdout.trim().length === 0;
39
+ }
40
+ /**
41
+ * Get current branch name.
42
+ */
43
+ async function getCurrentBranch(repoPath) {
44
+ const result = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath });
45
+ return result.stdout.trim();
46
+ }
47
+ /**
48
+ * Get conflicted files (sorted alphabetically).
49
+ */
50
+ async function getConflictedFiles(repoPath) {
51
+ try {
52
+ const result = await execa('git', ['diff', '--name-only', '--diff-filter=U'], {
53
+ cwd: repoPath
54
+ });
55
+ return result.stdout
56
+ .split('\n')
57
+ .map(f => f.trim())
58
+ .filter(Boolean)
59
+ .sort();
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ /**
66
+ * Get files changed in a commit.
67
+ */
68
+ async function getFilesInCommit(repoPath, sha) {
69
+ try {
70
+ const result = await execa('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', sha], {
71
+ cwd: repoPath
72
+ });
73
+ return result.stdout
74
+ .split('\n')
75
+ .map(f => f.trim())
76
+ .filter(Boolean);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ /**
83
+ * Check if cherry-pick would cause conflict (dry-run).
84
+ */
85
+ async function wouldCherryPickConflict(repoPath, targetBranch, sha) {
86
+ try {
87
+ // Try cherry-pick with --no-commit to detect conflicts without making changes
88
+ await execa('git', ['cherry-pick', '--no-commit', sha], { cwd: repoPath });
89
+ // If successful, reset and report no conflicts
90
+ await execa('git', ['reset', '--hard', 'HEAD'], { cwd: repoPath });
91
+ return { conflicts: false, files: [] };
92
+ }
93
+ catch {
94
+ // Get conflicted files
95
+ const files = await getConflictedFiles(repoPath);
96
+ // Abort and reset
97
+ try {
98
+ await execa('git', ['cherry-pick', '--abort'], { cwd: repoPath });
99
+ }
100
+ catch {
101
+ // Reset as fallback
102
+ await execa('git', ['reset', '--hard', 'HEAD'], { cwd: repoPath });
103
+ }
104
+ return { conflicts: true, files };
105
+ }
106
+ }
107
+ /**
108
+ * Emit validation failure event and exit.
109
+ */
110
+ function failValidation(runStore, runId, reason, details) {
111
+ runStore.appendEvent({
112
+ type: 'submit_validation_failed',
113
+ source: 'submit',
114
+ payload: {
115
+ run_id: runId,
116
+ reason,
117
+ details
118
+ }
119
+ });
120
+ console.error(`Submit blocked: ${reason}`);
121
+ console.error(details);
122
+ process.exitCode = 1;
123
+ }
124
+ /**
125
+ * Submit command: Cherry-pick verified checkpoint to integration branch.
126
+ */
127
+ export async function submitCommand(options) {
128
+ const runStore = RunStore.init(options.runId, options.repo);
129
+ // Load run state
130
+ let state;
131
+ try {
132
+ state = runStore.readState();
133
+ }
134
+ catch {
135
+ console.error(`Error: run state not found for ${options.runId}`);
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ // Load config with workflow settings
140
+ const config = loadConfig(resolveConfigPath(options.repo, options.config));
141
+ // Get workflow config (use safe defaults if not configured)
142
+ const workflow = config.workflow ?? {
143
+ profile: 'solo',
144
+ mode: 'flow',
145
+ integration_branch: 'dev',
146
+ require_clean_tree: true,
147
+ require_verification: true,
148
+ submit_strategy: 'cherry-pick'
149
+ };
150
+ // Validation: checkpoint exists in state
151
+ const checkpointSha = state.checkpoint_commit_sha;
152
+ if (!checkpointSha) {
153
+ failValidation(runStore, options.runId, 'no_checkpoint', 'Run has no checkpoint_commit_sha in state.json');
154
+ return;
155
+ }
156
+ // Validation: checkpoint exists as git object
157
+ if (!(await objectExists(options.repo, checkpointSha))) {
158
+ failValidation(runStore, options.runId, 'run_not_ready', `Checkpoint commit not found locally: ${checkpointSha}`);
159
+ return;
160
+ }
161
+ // Validation: verification evidence (if required)
162
+ if (workflow.require_verification) {
163
+ if (!state.last_verification_evidence) {
164
+ failValidation(runStore, options.runId, 'verification_missing', 'Verification required but last_verification_evidence is missing');
165
+ return;
166
+ }
167
+ }
168
+ // Validation: clean working tree (if required)
169
+ if (workflow.require_clean_tree) {
170
+ if (!(await isWorkingTreeClean(options.repo))) {
171
+ failValidation(runStore, options.runId, 'dirty_tree', 'Working tree is not clean (uncommitted changes present)');
172
+ return;
173
+ }
174
+ }
175
+ // Validation: target branch exists
176
+ const targetBranch = options.to ?? workflow.integration_branch;
177
+ if (!(await branchExists(options.repo, targetBranch))) {
178
+ failValidation(runStore, options.runId, 'target_branch_missing', `Target branch does not exist: ${targetBranch}`);
179
+ return;
180
+ }
181
+ // DRY RUN: print plan and check for conflicts (no events, no changes)
182
+ if (options.dryRun) {
183
+ console.log('Submit plan (dry-run):');
184
+ console.log(` run_id: ${options.runId}`);
185
+ console.log(` checkpoint: ${checkpointSha}`);
186
+ console.log(` target: ${targetBranch}`);
187
+ console.log(` strategy: cherry-pick`);
188
+ console.log(` push: ${options.push ? 'yes' : 'no'}`);
189
+ // Check for potential conflicts without making changes
190
+ const startingBranch = await getCurrentBranch(options.repo);
191
+ try {
192
+ await execa('git', ['checkout', targetBranch], { cwd: options.repo });
193
+ const conflictCheck = await wouldCherryPickConflict(options.repo, targetBranch, checkpointSha);
194
+ if (conflictCheck.conflicts) {
195
+ console.log('');
196
+ console.log(' ⚠️ Would conflict:');
197
+ for (const file of conflictCheck.files) {
198
+ console.log(` - ${file}`);
199
+ }
200
+ }
201
+ else {
202
+ console.log('');
203
+ console.log(' ✓ No conflicts detected');
204
+ }
205
+ }
206
+ finally {
207
+ // Always restore starting branch
208
+ try {
209
+ await execa('git', ['checkout', startingBranch], { cwd: options.repo });
210
+ }
211
+ catch {
212
+ // Best effort
213
+ }
214
+ }
215
+ return;
216
+ }
217
+ // Capture starting branch for restoration
218
+ const startingBranch = await getCurrentBranch(options.repo);
219
+ try {
220
+ // Checkout target branch
221
+ await execa('git', ['checkout', targetBranch], { cwd: options.repo });
222
+ // Cherry-pick checkpoint
223
+ try {
224
+ await execa('git', ['cherry-pick', checkpointSha], { cwd: options.repo });
225
+ }
226
+ catch {
227
+ // Get conflicted files
228
+ const conflictedFiles = await getConflictedFiles(options.repo);
229
+ // Abort cherry-pick
230
+ try {
231
+ await execa('git', ['cherry-pick', '--abort'], { cwd: options.repo });
232
+ }
233
+ catch {
234
+ // Ignore abort errors
235
+ }
236
+ // Restore starting branch (best-effort)
237
+ try {
238
+ await execa('git', ['checkout', startingBranch], { cwd: options.repo });
239
+ }
240
+ catch {
241
+ // Ignore restoration errors
242
+ }
243
+ // Verify tree is actually clean after abort
244
+ const treeClean = await isWorkingTreeClean(options.repo);
245
+ const currentBranch = await getCurrentBranch(options.repo);
246
+ const branchRestored = currentBranch === startingBranch;
247
+ // Log warnings if invariants fail
248
+ if (!branchRestored) {
249
+ console.warn(`Warning: Could not restore branch. Expected ${startingBranch}, got ${currentBranch}`);
250
+ }
251
+ if (!treeClean) {
252
+ console.warn('Warning: Working tree is not clean after conflict cleanup');
253
+ }
254
+ // Emit enhanced conflict event
255
+ runStore.appendEvent({
256
+ type: 'submit_conflict',
257
+ source: 'submit',
258
+ payload: {
259
+ run_id: options.runId,
260
+ checkpoint_sha: checkpointSha,
261
+ target_branch: targetBranch,
262
+ conflicted_files: conflictedFiles,
263
+ recovery_branch: currentBranch,
264
+ recovery_state: treeClean ? 'clean' : 'dirty',
265
+ suggested_commands: [
266
+ `git checkout ${targetBranch}`,
267
+ `git cherry-pick ${checkpointSha}`,
268
+ '# resolve conflicts',
269
+ 'git add .',
270
+ 'git cherry-pick --continue'
271
+ ]
272
+ }
273
+ });
274
+ // Print conflict message with recovery recipe (exact spec format)
275
+ console.error('');
276
+ console.error('Cherry-pick conflict detected.');
277
+ console.error('');
278
+ console.error(`Checkpoint: ${checkpointSha}`);
279
+ console.error(`Target: ${targetBranch}`);
280
+ console.error('');
281
+ console.error('Conflicted files:');
282
+ for (const file of conflictedFiles) {
283
+ console.error(` - ${file}`);
284
+ }
285
+ console.error('');
286
+ // Recovery state with checkmarks
287
+ console.error('Recovery state:');
288
+ if (branchRestored) {
289
+ console.error(` ✓ Branch restored to ${startingBranch}`);
290
+ }
291
+ else {
292
+ console.error(` ✗ Branch NOT restored (currently on ${currentBranch})`);
293
+ }
294
+ if (treeClean) {
295
+ console.error(' ✓ Working tree is clean');
296
+ }
297
+ else {
298
+ console.error(' ✗ Working tree is NOT clean');
299
+ }
300
+ console.error('');
301
+ // Copy-paste ready recovery commands
302
+ console.error('To resolve manually:');
303
+ console.error(` git checkout ${targetBranch}`);
304
+ console.error(` git cherry-pick ${checkpointSha}`);
305
+ console.error(' # resolve conflicts');
306
+ console.error(' git add .');
307
+ console.error(' git cherry-pick --continue');
308
+ // Conditional CHANGELOG tip:
309
+ // - Only show if CHANGELOG.md exists
310
+ // - Only show if checkpoint doesn't already modify CHANGELOG.md
311
+ // - Suppress if CHANGELOG is in conflicted files (already mentioned above)
312
+ const changelogPath = path.join(options.repo, 'CHANGELOG.md');
313
+ const changelogExists = fs.existsSync(changelogPath);
314
+ const checkpointModifiesChangelog = (await getFilesInCommit(options.repo, checkpointSha))
315
+ .some(f => f.toLowerCase().includes('changelog'));
316
+ const changelogInConflicts = conflictedFiles.some(f => f.toLowerCase().includes('changelog'));
317
+ if (changelogExists && !checkpointModifiesChangelog && !changelogInConflicts) {
318
+ console.error('');
319
+ console.error('If this adds new features, consider updating CHANGELOG.md.');
320
+ }
321
+ else if (changelogInConflicts) {
322
+ console.error('');
323
+ console.error('Tip: CHANGELOG.md conflicts are common; consider moving');
324
+ console.error(' changelog updates into a dedicated task.');
325
+ }
326
+ console.error('');
327
+ process.exitCode = 1;
328
+ return;
329
+ }
330
+ // Push to origin (if requested)
331
+ if (options.push) {
332
+ try {
333
+ await execa('git', ['push', 'origin', targetBranch], { cwd: options.repo });
334
+ }
335
+ catch (error) {
336
+ console.error('Warning: cherry-pick succeeded but push failed');
337
+ console.error(String(error));
338
+ // Don't fail - cherry-pick succeeded, which is the primary goal
339
+ }
340
+ }
341
+ // Emit success event
342
+ runStore.appendEvent({
343
+ type: 'run_submitted',
344
+ source: 'submit',
345
+ payload: {
346
+ run_id: options.runId,
347
+ checkpoint_sha: checkpointSha,
348
+ target_branch: targetBranch,
349
+ strategy: 'cherry-pick',
350
+ submitted_at: new Date().toISOString()
351
+ }
352
+ });
353
+ // Clear active state sentinel (run is now submitted)
354
+ clearActiveState(options.repo);
355
+ console.log(`✓ Submitted ${checkpointSha} to ${targetBranch}`);
356
+ }
357
+ catch (error) {
358
+ // Git error (not conflict)
359
+ failValidation(runStore, options.runId, 'git_error', `Git error: ${String(error)}`);
360
+ return;
361
+ }
362
+ finally {
363
+ // Always restore starting branch (best-effort)
364
+ try {
365
+ const currentBranch = await getCurrentBranch(options.repo);
366
+ if (currentBranch !== startingBranch) {
367
+ await execa('git', ['checkout', startingBranch], { cwd: options.repo });
368
+ }
369
+ }
370
+ catch {
371
+ // Ignore restoration errors (best-effort)
372
+ }
373
+ }
374
+ }
@@ -149,6 +149,30 @@ const resilienceSchema = z.object({
149
149
  /** Maximum review rounds per milestone before stopping with review_loop_detected (default: 2) */
150
150
  max_review_rounds: z.number().int().positive().default(2)
151
151
  });
152
+ // Receipts configuration for output capture and redaction
153
+ const receiptsConfigSchema = z.object({
154
+ /** Enable secret redaction in command output */
155
+ redact: z.boolean().default(true),
156
+ /** How much command output to capture: full, truncated, or metadata_only */
157
+ capture_cmd_output: z.enum(['full', 'truncated', 'metadata_only']).default('truncated'),
158
+ /** Maximum output bytes to store (when truncated) */
159
+ max_output_bytes: z.number().int().positive().default(10240) // 10KB
160
+ });
161
+ // Workflow configuration for integration strategy
162
+ const workflowConfigSchema = z.object({
163
+ /** Workflow profile preset (solo/pr/trunk) */
164
+ profile: z.enum(['solo', 'pr', 'trunk']).default('solo'),
165
+ /** Workflow mode: flow (permissive) or ledger (strict) */
166
+ mode: z.enum(['flow', 'ledger']).default('flow'),
167
+ /** Target branch for integrating verified checkpoints */
168
+ integration_branch: z.string(),
169
+ /** Submit strategy (v1: cherry-pick only) */
170
+ submit_strategy: z.literal('cherry-pick').default('cherry-pick'),
171
+ /** Require clean working tree before submit */
172
+ require_clean_tree: z.boolean().default(true),
173
+ /** Require verification evidence before submit */
174
+ require_verification: z.boolean().default(true)
175
+ });
152
176
  export const agentConfigSchema = z.object({
153
177
  agent: agentSchema,
154
178
  repo: repoSchema.default({}),
@@ -156,5 +180,41 @@ export const agentConfigSchema = z.object({
156
180
  verification: verificationSchema,
157
181
  workers: workersSchema.default({}),
158
182
  phases: phasesSchema.default({}),
159
- resilience: resilienceSchema.default({})
183
+ resilience: resilienceSchema.default({}),
184
+ workflow: workflowConfigSchema.optional(),
185
+ receipts: receiptsConfigSchema.default({})
160
186
  });
187
+ /**
188
+ * Get default workflow config values for a given profile.
189
+ */
190
+ export function getWorkflowProfileDefaults(profile) {
191
+ switch (profile) {
192
+ case 'solo':
193
+ return {
194
+ profile: 'solo',
195
+ mode: 'flow',
196
+ integration_branch: 'dev',
197
+ submit_strategy: 'cherry-pick',
198
+ require_clean_tree: true,
199
+ require_verification: true
200
+ };
201
+ case 'pr':
202
+ return {
203
+ profile: 'pr',
204
+ mode: 'flow',
205
+ integration_branch: 'main',
206
+ submit_strategy: 'cherry-pick',
207
+ require_clean_tree: true,
208
+ require_verification: false
209
+ };
210
+ case 'trunk':
211
+ return {
212
+ profile: 'trunk',
213
+ mode: 'ledger',
214
+ integration_branch: 'main',
215
+ submit_strategy: 'cherry-pick',
216
+ require_clean_tree: true,
217
+ require_verification: true
218
+ };
219
+ }
220
+ }
@@ -20,6 +20,8 @@ function categoryToFamily(category) {
20
20
  return 'guard';
21
21
  case 'verification_failure':
22
22
  return 'verification';
23
+ case 'review_loop_detected':
24
+ return 'review';
23
25
  case 'worker_parse_failure':
24
26
  return 'worker';
25
27
  case 'stall_timeout':
@@ -69,7 +71,8 @@ export function diagnoseStop(context) {
69
71
  diagnoseStallTimeout(context),
70
72
  diagnoseMaxTicksReached(context),
71
73
  diagnoseTimeBudgetExceeded(context),
72
- diagnoseGuardViolationDirty(context)
74
+ diagnoseGuardViolationDirty(context),
75
+ diagnoseReviewLoopDetected(context)
73
76
  ].filter((r) => r.confidence > 0);
74
77
  // Sort by confidence descending
75
78
  results.sort((a, b) => b.confidence - a.confidence);
@@ -647,6 +650,88 @@ function diagnoseGuardViolationDirty(ctx) {
647
650
  : []
648
651
  };
649
652
  }
653
+ /**
654
+ * Rule 11: Review loop detected
655
+ */
656
+ function diagnoseReviewLoopDetected(ctx) {
657
+ const signals = [];
658
+ let confidence = 0;
659
+ let pattern = 'unknown';
660
+ let reviewRounds = 0;
661
+ let maxReviewRounds = 2;
662
+ let milestoneIndex = ctx.state.milestone_index;
663
+ let requestedChanges = [];
664
+ // Check stop reason or find event
665
+ if (ctx.state.stop_reason === 'review_loop_detected') {
666
+ confidence = 0.9;
667
+ // Find the review_loop_detected event for details
668
+ const event = ctx.events.find((e) => e.type === 'review_loop_detected');
669
+ if (event?.payload) {
670
+ const payload = event.payload;
671
+ const sameFingerprint = payload.same_fingerprint;
672
+ reviewRounds = payload.review_rounds ?? 0;
673
+ maxReviewRounds = payload.max_review_rounds ?? 2;
674
+ milestoneIndex = payload.milestone_index ?? ctx.state.milestone_index;
675
+ const lastChanges = payload.last_changes;
676
+ pattern = sameFingerprint ? 'identical_review_feedback' : 'max_review_rounds_exceeded';
677
+ if (lastChanges && lastChanges.length > 0) {
678
+ requestedChanges = lastChanges.slice(0, 2);
679
+ }
680
+ signals.push({
681
+ source: 'event.review_loop_detected',
682
+ pattern,
683
+ snippet: `Milestone ${milestoneIndex + 1}, ${reviewRounds} rounds (max: ${maxReviewRounds}), changes: ${requestedChanges.join('; ')}`
684
+ });
685
+ }
686
+ else {
687
+ // Event not found but stop_reason matches
688
+ signals.push({
689
+ source: 'state.stop_reason',
690
+ pattern: 'review_loop_detected',
691
+ snippet: ctx.state.last_error?.slice(0, 200) || 'Review loop detected'
692
+ });
693
+ confidence = 0.7;
694
+ }
695
+ }
696
+ return {
697
+ category: 'review_loop_detected',
698
+ confidence,
699
+ signals,
700
+ nextActions: confidence > 0
701
+ ? [
702
+ {
703
+ title: 'Open run artifacts',
704
+ command: `node dist/cli.js open ${ctx.runId}`,
705
+ why: 'View all run artifacts including review digest and timeline'
706
+ },
707
+ {
708
+ title: 'Read review digest',
709
+ command: `cat ${ctx.runDir}/review_digest.md`,
710
+ why: 'See exact requested changes and review verdict'
711
+ },
712
+ {
713
+ title: 'View run journal',
714
+ command: `node dist/cli.js journal ${ctx.runId}`,
715
+ why: 'Understand what the agent did across review rounds'
716
+ },
717
+ {
718
+ title: 'Rewrite milestone acceptance criteria',
719
+ why: 'Make criteria explicit as 3-7 checkboxes with concrete file paths and testable conditions. Loops usually mean the agent cannot translate review feedback into deterministic work.'
720
+ },
721
+ {
722
+ title: 'Check resume plan',
723
+ command: `node dist/cli.js resume ${ctx.runId} --plan`,
724
+ why: 'Preview what resume will do before running it'
725
+ },
726
+ {
727
+ title: 'Run diagnostics',
728
+ command: `node dist/cli.js doctor`,
729
+ why: 'Verify environment and repository health'
730
+ }
731
+ ]
732
+ : []
733
+ };
734
+ }
650
735
  // ============================================================================
651
736
  // Helpers
652
737
  // ============================================================================
@@ -16,6 +16,7 @@ const categoryDescriptions = {
16
16
  time_budget_exceeded: 'Ran out of allocated time.',
17
17
  guard_violation_dirty: 'Working directory has uncommitted changes.',
18
18
  ownership_violation: 'Task modified files outside its declared owns: paths.',
19
+ review_loop_detected: 'Review feedback repeated or max review rounds exceeded.',
19
20
  unknown: 'Could not determine specific cause.'
20
21
  };
21
22
  /**
@@ -130,6 +131,8 @@ function getEscalationAdvice(category) {
130
131
  return 'For complex tasks, allocate more time upfront: --time 120 or higher.';
131
132
  case 'guard_violation_dirty':
132
133
  return 'Always use --worktree for runs on repos with active development.';
134
+ case 'review_loop_detected':
135
+ return 'If feedback loops persist, split the milestone into smaller steps or add explicit acceptance criteria as checkboxes. Consider adjusting verification commands to catch issues earlier.';
133
136
  default:
134
137
  return 'Review the timeline and logs carefully. Open an issue if the problem persists.';
135
138
  }
@@ -4,3 +4,4 @@
4
4
  export * from './types.js';
5
5
  export * from './analyzer.js';
6
6
  export * from './formatter.js';
7
+ export * from './stop-explainer.js';