@weldr/runr 0.3.0

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 (73) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +4 -0
  4. package/README.md +200 -0
  5. package/dist/cli.js +464 -0
  6. package/dist/commands/__tests__/report.test.js +202 -0
  7. package/dist/commands/compare.js +168 -0
  8. package/dist/commands/doctor.js +124 -0
  9. package/dist/commands/follow.js +251 -0
  10. package/dist/commands/gc.js +161 -0
  11. package/dist/commands/guards-only.js +89 -0
  12. package/dist/commands/metrics.js +441 -0
  13. package/dist/commands/orchestrate.js +800 -0
  14. package/dist/commands/paths.js +31 -0
  15. package/dist/commands/preflight.js +152 -0
  16. package/dist/commands/report.js +478 -0
  17. package/dist/commands/resume.js +149 -0
  18. package/dist/commands/run.js +538 -0
  19. package/dist/commands/status.js +189 -0
  20. package/dist/commands/summarize.js +220 -0
  21. package/dist/commands/version.js +82 -0
  22. package/dist/commands/wait.js +170 -0
  23. package/dist/config/__tests__/presets.test.js +104 -0
  24. package/dist/config/load.js +66 -0
  25. package/dist/config/schema.js +160 -0
  26. package/dist/context/__tests__/artifact.test.js +130 -0
  27. package/dist/context/__tests__/pack.test.js +191 -0
  28. package/dist/context/artifact.js +67 -0
  29. package/dist/context/index.js +2 -0
  30. package/dist/context/pack.js +273 -0
  31. package/dist/diagnosis/analyzer.js +678 -0
  32. package/dist/diagnosis/formatter.js +136 -0
  33. package/dist/diagnosis/index.js +6 -0
  34. package/dist/diagnosis/types.js +7 -0
  35. package/dist/env/__tests__/fingerprint.test.js +116 -0
  36. package/dist/env/fingerprint.js +111 -0
  37. package/dist/orchestrator/__tests__/policy.test.js +185 -0
  38. package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
  39. package/dist/orchestrator/artifacts.js +405 -0
  40. package/dist/orchestrator/state-machine.js +646 -0
  41. package/dist/orchestrator/types.js +88 -0
  42. package/dist/ownership/normalize.js +45 -0
  43. package/dist/repo/context.js +90 -0
  44. package/dist/repo/git.js +13 -0
  45. package/dist/repo/worktree.js +239 -0
  46. package/dist/store/run-store.js +107 -0
  47. package/dist/store/run-utils.js +69 -0
  48. package/dist/store/runs-root.js +126 -0
  49. package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
  50. package/dist/supervisor/__tests__/ownership.test.js +103 -0
  51. package/dist/supervisor/__tests__/state-machine.test.js +290 -0
  52. package/dist/supervisor/collision.js +240 -0
  53. package/dist/supervisor/evidence-gate.js +98 -0
  54. package/dist/supervisor/planner.js +18 -0
  55. package/dist/supervisor/runner.js +1562 -0
  56. package/dist/supervisor/scope-guard.js +55 -0
  57. package/dist/supervisor/state-machine.js +121 -0
  58. package/dist/supervisor/verification-policy.js +64 -0
  59. package/dist/tasks/task-metadata.js +72 -0
  60. package/dist/types/schemas.js +1 -0
  61. package/dist/verification/engine.js +49 -0
  62. package/dist/workers/__tests__/claude.test.js +88 -0
  63. package/dist/workers/__tests__/codex.test.js +81 -0
  64. package/dist/workers/claude.js +119 -0
  65. package/dist/workers/codex.js +162 -0
  66. package/dist/workers/json.js +22 -0
  67. package/dist/workers/mock.js +193 -0
  68. package/dist/workers/prompts.js +98 -0
  69. package/dist/workers/schemas.js +39 -0
  70. package/package.json +47 -0
  71. package/templates/prompts/implementer.md +70 -0
  72. package/templates/prompts/planner.md +62 -0
  73. package/templates/prompts/reviewer.md +77 -0
@@ -0,0 +1,126 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ // Track if we've shown the deprecation warning this session
4
+ let shownLegacyWarning = false;
5
+ /**
6
+ * Get all canonical runr paths for a repository.
7
+ * This is the single source of truth - import this everywhere.
8
+ *
9
+ * Layout (new):
10
+ * ```
11
+ * .runr/
12
+ * runs/<runId>/...
13
+ * orchestrations/<orchId>/...
14
+ * .runr-worktrees/
15
+ * <runId>/
16
+ * ```
17
+ *
18
+ * Layout (legacy, still supported):
19
+ * ```
20
+ * .agent/
21
+ * runs/<runId>/...
22
+ * orchestrations/<orchId>/...
23
+ * .agent-worktrees/
24
+ * <runId>/
25
+ * ```
26
+ *
27
+ * Worktrees are stored OUTSIDE .runr/ to avoid conflicts with denylist patterns
28
+ * like `.runr/**`. This prevents both:
29
+ * 1. Git-level dirtiness (parent repo seeing worktree as untracked files)
30
+ * 2. Worker-level confusion (absolute CWD containing `.runr/` matching denylist)
31
+ *
32
+ * Override worktrees location with RUNR_WORKTREES_DIR (or legacy AGENT_WORKTREES_DIR) env var.
33
+ *
34
+ * @param repoPath - The target repository path
35
+ * @returns All runr paths as absolute paths
36
+ */
37
+ export function getRunrPaths(repoPath) {
38
+ const repoRoot = path.resolve(repoPath);
39
+ // Check for new .runr/ directory first, fall back to legacy .agent/
40
+ const newRoot = path.join(repoRoot, '.runr');
41
+ const legacyRoot = path.join(repoRoot, '.agent');
42
+ let runrRoot;
43
+ let usingLegacy = false;
44
+ if (fs.existsSync(newRoot)) {
45
+ runrRoot = newRoot;
46
+ }
47
+ else if (fs.existsSync(legacyRoot)) {
48
+ runrRoot = legacyRoot;
49
+ usingLegacy = true;
50
+ if (!shownLegacyWarning) {
51
+ console.warn('\x1b[33m⚠ Deprecation: .agent/ directory is deprecated. Rename to .runr/\x1b[0m');
52
+ shownLegacyWarning = true;
53
+ }
54
+ }
55
+ else {
56
+ // Neither exists - default to new location (will be created on first run)
57
+ runrRoot = newRoot;
58
+ }
59
+ // Worktrees: check new env var first, then legacy, then default
60
+ const worktreesOverride = process.env.RUNR_WORKTREES_DIR || process.env.AGENT_WORKTREES_DIR;
61
+ let worktreesDir;
62
+ if (worktreesOverride) {
63
+ worktreesDir = path.isAbsolute(worktreesOverride)
64
+ ? worktreesOverride
65
+ : path.resolve(repoRoot, worktreesOverride);
66
+ }
67
+ else if (usingLegacy) {
68
+ worktreesDir = path.join(repoRoot, '.agent-worktrees');
69
+ }
70
+ else {
71
+ worktreesDir = path.join(repoRoot, '.runr-worktrees');
72
+ }
73
+ return {
74
+ repo_root: repoRoot,
75
+ runr_root: runrRoot,
76
+ runs_dir: path.join(runrRoot, 'runs'),
77
+ worktrees_dir: worktreesDir,
78
+ orchestrations_dir: path.join(runrRoot, 'orchestrations'),
79
+ using_legacy: usingLegacy
80
+ };
81
+ }
82
+ /**
83
+ * @deprecated Use getRunrPaths instead
84
+ */
85
+ export function getAgentPaths(repoPath) {
86
+ return getRunrPaths(repoPath);
87
+ }
88
+ /**
89
+ * Get the runs root directory for a given repo path.
90
+ * @deprecated Use getRunrPaths(repoPath).runs_dir instead
91
+ */
92
+ export function getRunsRoot(repoPath) {
93
+ return getRunrPaths(repoPath).runs_dir;
94
+ }
95
+ /**
96
+ * Get the worktrees root directory for a given repo path.
97
+ */
98
+ export function getWorktreesRoot(repoPath) {
99
+ return getRunrPaths(repoPath).worktrees_dir;
100
+ }
101
+ /**
102
+ * Get the run directory for a specific run ID within a repo.
103
+ *
104
+ * @param repoPath - The target repository path
105
+ * @param runId - The run ID (timestamp format)
106
+ * @returns The absolute path to the run directory
107
+ */
108
+ export function getRunDir(repoPath, runId) {
109
+ return path.join(getRunrPaths(repoPath).runs_dir, runId);
110
+ }
111
+ /**
112
+ * Get the orchestrations root directory.
113
+ *
114
+ * @param repoPath - The target repository path
115
+ * @returns The absolute path to the orchestrations directory
116
+ */
117
+ export function getOrchestrationsRoot(repoPath) {
118
+ return getRunrPaths(repoPath).orchestrations_dir;
119
+ }
120
+ /**
121
+ * Legacy orchestrations path (for migration).
122
+ * Old location was nested under runs.
123
+ */
124
+ export function getLegacyOrchestrationsRoot(repoPath) {
125
+ return path.join(getRunrPaths(repoPath).runs_dir, 'orchestrations');
126
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateNoChangesEvidence, formatEvidenceErrors } from '../evidence-gate.js';
3
+ describe('validateNoChangesEvidence', () => {
4
+ const allowlist = ['src/game/**', 'src/utils/**'];
5
+ describe('when no evidence provided', () => {
6
+ it('should fail with no evidence', () => {
7
+ const result = validateNoChangesEvidence(undefined, allowlist);
8
+ expect(result.ok).toBe(false);
9
+ expect(result.errors).toContain('No evidence provided for no_changes_needed claim');
10
+ });
11
+ it('should fail with empty evidence object', () => {
12
+ const result = validateNoChangesEvidence({}, allowlist);
13
+ expect(result.ok).toBe(false);
14
+ expect(result.errors.length).toBeGreaterThan(0);
15
+ });
16
+ });
17
+ describe('files_checked evidence', () => {
18
+ it('should pass with files_checked in scope', () => {
19
+ const result = validateNoChangesEvidence({ files_checked: ['src/game/combat.ts', 'src/game/utils.ts'] }, allowlist);
20
+ expect(result.ok).toBe(true);
21
+ expect(result.satisfied_by).toBe('files_checked');
22
+ });
23
+ it('should fail with files_checked out of scope', () => {
24
+ const result = validateNoChangesEvidence({ files_checked: ['package.json', 'src/game/combat.ts'] }, allowlist);
25
+ expect(result.ok).toBe(false);
26
+ expect(result.errors.some(e => e.includes('outside scope'))).toBe(true);
27
+ });
28
+ it('should fail with empty files_checked array', () => {
29
+ const result = validateNoChangesEvidence({ files_checked: [] }, allowlist);
30
+ expect(result.ok).toBe(false);
31
+ });
32
+ });
33
+ describe('grep_output evidence', () => {
34
+ it('should pass with non-empty grep_output', () => {
35
+ const result = validateNoChangesEvidence({ grep_output: 'src/game/combat.ts:42: function alreadyImplemented()' }, allowlist);
36
+ expect(result.ok).toBe(true);
37
+ expect(result.satisfied_by).toBe('grep_output');
38
+ });
39
+ it('should fail with empty grep_output', () => {
40
+ const result = validateNoChangesEvidence({ grep_output: '' }, allowlist);
41
+ expect(result.ok).toBe(false);
42
+ });
43
+ it('should fail with whitespace-only grep_output', () => {
44
+ const result = validateNoChangesEvidence({ grep_output: ' \n\t ' }, allowlist);
45
+ expect(result.ok).toBe(false);
46
+ });
47
+ });
48
+ describe('commands_run evidence', () => {
49
+ it('should pass with commands_run all exit_code 0', () => {
50
+ const result = validateNoChangesEvidence({
51
+ commands_run: [
52
+ { command: 'grep -r "feature" src/', exit_code: 0 },
53
+ { command: 'test -f src/feature.ts', exit_code: 0 }
54
+ ]
55
+ }, allowlist);
56
+ expect(result.ok).toBe(true);
57
+ expect(result.satisfied_by).toBe('commands_run');
58
+ });
59
+ it('should fail with commands_run containing non-zero exit_code', () => {
60
+ const result = validateNoChangesEvidence({
61
+ commands_run: [
62
+ { command: 'grep -r "feature" src/', exit_code: 1 }
63
+ ]
64
+ }, allowlist);
65
+ expect(result.ok).toBe(false);
66
+ expect(result.errors.some(e => e.includes('failed commands'))).toBe(true);
67
+ });
68
+ it('should fail with empty commands_run array', () => {
69
+ const result = validateNoChangesEvidence({ commands_run: [] }, allowlist);
70
+ expect(result.ok).toBe(false);
71
+ });
72
+ });
73
+ describe('priority order', () => {
74
+ it('should prefer files_checked over grep_output', () => {
75
+ const result = validateNoChangesEvidence({
76
+ files_checked: ['src/game/combat.ts'],
77
+ grep_output: 'some output'
78
+ }, allowlist);
79
+ expect(result.ok).toBe(true);
80
+ expect(result.satisfied_by).toBe('files_checked');
81
+ });
82
+ it('should fall back to grep_output if files_checked is out of scope', () => {
83
+ const result = validateNoChangesEvidence({
84
+ files_checked: ['package.json'],
85
+ grep_output: 'some valid output'
86
+ }, allowlist);
87
+ expect(result.ok).toBe(true);
88
+ expect(result.satisfied_by).toBe('grep_output');
89
+ });
90
+ });
91
+ });
92
+ describe('formatEvidenceErrors', () => {
93
+ it('should return empty string for ok result', () => {
94
+ const result = formatEvidenceErrors({
95
+ ok: true,
96
+ errors: [],
97
+ satisfied_by: 'files_checked'
98
+ });
99
+ expect(result).toBe('');
100
+ });
101
+ it('should format errors for failed result', () => {
102
+ const result = formatEvidenceErrors({
103
+ ok: false,
104
+ errors: ['No evidence provided', 'files_checked is empty']
105
+ });
106
+ expect(result).toContain('Insufficient evidence');
107
+ expect(result).toContain('No evidence provided');
108
+ expect(result).toContain('files_checked is empty');
109
+ expect(result).toContain('Required:');
110
+ });
111
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { checkOwnership } from '../runner.js';
3
+ describe('checkOwnership', () => {
4
+ describe('when no ownership declared', () => {
5
+ it('does not enforce - always returns ok', () => {
6
+ const result = checkOwnership(['README.md', 'src/index.ts', 'package.json'], [], // no ownership declared
7
+ ['node_modules/**']);
8
+ expect(result.ok).toBe(true);
9
+ expect(result.owned_paths).toEqual([]);
10
+ expect(result.semantic_changed).toEqual([]);
11
+ expect(result.violating_files).toEqual([]);
12
+ });
13
+ it('does not enforce even with changes outside would-be owns', () => {
14
+ // This is the key test: tasks without owns should continue to work unchanged
15
+ const result = checkOwnership(['README.md', 'docs/guide.md', 'src/unrelated.ts'], [], // no ownership - no enforcement
16
+ []);
17
+ expect(result.ok).toBe(true);
18
+ expect(result.violating_files).toEqual([]);
19
+ });
20
+ });
21
+ describe('when ownership declared', () => {
22
+ it('allows changes within owned paths', () => {
23
+ const result = checkOwnership(['courses/a/lesson1.md', 'courses/a/lesson2.md'], ['courses/a/**'], []);
24
+ expect(result.ok).toBe(true);
25
+ expect(result.owned_paths).toEqual(['courses/a/**']);
26
+ expect(result.semantic_changed).toEqual(['courses/a/lesson1.md', 'courses/a/lesson2.md']);
27
+ expect(result.violating_files).toEqual([]);
28
+ });
29
+ it('detects violation when changing files outside owned paths', () => {
30
+ const result = checkOwnership(['courses/a/lesson1.md', 'README.md'], ['courses/a/**'], []);
31
+ expect(result.ok).toBe(false);
32
+ expect(result.owned_paths).toEqual(['courses/a/**']);
33
+ expect(result.semantic_changed).toEqual(['courses/a/lesson1.md', 'README.md']);
34
+ expect(result.violating_files).toEqual(['README.md']);
35
+ });
36
+ it('reports all violating files', () => {
37
+ const result = checkOwnership(['courses/a/lesson.md', 'README.md', 'docs/guide.md', 'src/index.ts'], ['courses/a/**'], []);
38
+ expect(result.ok).toBe(false);
39
+ expect(result.violating_files).toEqual(['README.md', 'docs/guide.md', 'src/index.ts']);
40
+ });
41
+ it('supports multiple owned paths', () => {
42
+ const result = checkOwnership(['courses/a/lesson.md', 'docs/a.md'], ['courses/a/**', 'docs/**'], []);
43
+ expect(result.ok).toBe(true);
44
+ expect(result.violating_files).toEqual([]);
45
+ });
46
+ });
47
+ describe('env artifact filtering', () => {
48
+ it('excludes env artifacts from ownership check', () => {
49
+ const result = checkOwnership(['courses/a/lesson.md', 'node_modules/foo/bar.js'], ['courses/a/**'], ['node_modules/**']);
50
+ expect(result.ok).toBe(true);
51
+ expect(result.semantic_changed).toEqual(['courses/a/lesson.md']);
52
+ expect(result.violating_files).toEqual([]);
53
+ });
54
+ it('only checks semantic changes, not env noise', () => {
55
+ // If all changes are env artifacts, no enforcement needed
56
+ const result = checkOwnership(['node_modules/foo/bar.js', '.next/cache/file'], ['courses/a/**'], ['node_modules/**', '.next/**']);
57
+ expect(result.ok).toBe(true);
58
+ expect(result.semantic_changed).toEqual([]);
59
+ expect(result.violating_files).toEqual([]);
60
+ });
61
+ });
62
+ describe('rename handling (when both paths are included)', () => {
63
+ it('blocks rename from outside into owned paths', () => {
64
+ // Simulates: git mv README.md courses/a/README.md
65
+ // listChangedFiles now returns BOTH paths
66
+ const result = checkOwnership(['README.md', 'courses/a/README.md'], ['courses/a/**'], []);
67
+ expect(result.ok).toBe(false);
68
+ expect(result.violating_files).toEqual(['README.md']);
69
+ });
70
+ it('allows rename within owned paths', () => {
71
+ // Simulates: git mv courses/a/old.md courses/a/new.md
72
+ const result = checkOwnership(['courses/a/old.md', 'courses/a/new.md'], ['courses/a/**'], []);
73
+ expect(result.ok).toBe(true);
74
+ expect(result.violating_files).toEqual([]);
75
+ });
76
+ it('blocks rename from owned to outside', () => {
77
+ // Simulates: git mv courses/a/file.md README.md
78
+ const result = checkOwnership(['courses/a/file.md', 'README.md'], ['courses/a/**'], []);
79
+ expect(result.ok).toBe(false);
80
+ expect(result.violating_files).toEqual(['README.md']);
81
+ });
82
+ });
83
+ describe('defensive normalization', () => {
84
+ it('normalizes raw directory paths to glob patterns', () => {
85
+ // Caller passes raw "courses/a/" instead of normalized "courses/a/**"
86
+ const result = checkOwnership(['courses/a/lesson.md'], ['courses/a/'], // raw, not normalized
87
+ []);
88
+ expect(result.ok).toBe(true);
89
+ expect(result.owned_paths).toEqual(['courses/a/**']); // normalized in output
90
+ });
91
+ it('normalizes Windows-style paths', () => {
92
+ const result = checkOwnership(['courses/a/lesson.md'], ['courses\\a\\'], // Windows-style
93
+ []);
94
+ expect(result.ok).toBe(true);
95
+ expect(result.owned_paths).toEqual(['courses/a/**']);
96
+ });
97
+ it('handles ./prefix in ownership patterns', () => {
98
+ const result = checkOwnership(['courses/a/lesson.md'], ['./courses/a/'], []);
99
+ expect(result.ok).toBe(true);
100
+ expect(result.owned_paths).toEqual(['courses/a/**']);
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createInitialState, updatePhase, stopRun, computeResumeTargetPhase, prepareForResume } from '../state-machine.js';
3
+ describe('createInitialState', () => {
4
+ it('creates state with INIT phase', () => {
5
+ const state = createInitialState({
6
+ run_id: 'test-run-123',
7
+ repo_path: '/test/repo',
8
+ task_text: 'Test task',
9
+ allowlist: ['src/**'],
10
+ denylist: ['node_modules/**']
11
+ });
12
+ expect(state.run_id).toBe('test-run-123');
13
+ expect(state.repo_path).toBe('/test/repo');
14
+ expect(state.phase).toBe('INIT');
15
+ expect(state.milestone_index).toBe(0);
16
+ expect(state.scope_lock.allowlist).toEqual(['src/**']);
17
+ expect(state.scope_lock.denylist).toEqual(['node_modules/**']);
18
+ expect(state.risk_score).toBe(0);
19
+ expect(state.retries).toBe(0);
20
+ });
21
+ it('parses task text into milestones', () => {
22
+ const state = createInitialState({
23
+ run_id: 'test',
24
+ repo_path: '/test',
25
+ task_text: 'Add a new feature\n\nMore details here',
26
+ allowlist: [],
27
+ denylist: []
28
+ });
29
+ expect(state.milestones.length).toBeGreaterThan(0);
30
+ expect(state.milestones[0].goal).toBe('Add a new feature');
31
+ });
32
+ });
33
+ describe('updatePhase', () => {
34
+ it('updates phase and tracks last_successful_phase as previous phase', () => {
35
+ const initial = createInitialState({
36
+ run_id: 'test',
37
+ repo_path: '/test',
38
+ task_text: 'Test',
39
+ allowlist: [],
40
+ denylist: []
41
+ });
42
+ const updated = updatePhase(initial, 'PLAN');
43
+ expect(updated.phase).toBe('PLAN');
44
+ // last_successful_phase tracks the previous phase (INIT in this case)
45
+ expect(updated.last_successful_phase).toBe('INIT');
46
+ // updated_at is set (may be same as initial if test runs in same ms)
47
+ expect(typeof updated.updated_at).toBe('string');
48
+ expect(updated.updated_at.length).toBeGreaterThan(0);
49
+ });
50
+ it('preserves other state properties', () => {
51
+ const initial = createInitialState({
52
+ run_id: 'preserve-test',
53
+ repo_path: '/preserve',
54
+ task_text: 'Preserve task',
55
+ allowlist: ['src/**'],
56
+ denylist: ['dist/**']
57
+ });
58
+ const updated = updatePhase(initial, 'IMPLEMENT');
59
+ expect(updated.run_id).toBe('preserve-test');
60
+ expect(updated.repo_path).toBe('/preserve');
61
+ expect(updated.scope_lock.allowlist).toEqual(['src/**']);
62
+ });
63
+ });
64
+ describe('stopRun', () => {
65
+ it('sets phase to STOPPED and records reason', () => {
66
+ const initial = createInitialState({
67
+ run_id: 'stop-test',
68
+ repo_path: '/test',
69
+ task_text: 'Test',
70
+ allowlist: [],
71
+ denylist: []
72
+ });
73
+ const stopped = stopRun(initial, 'verification_failed');
74
+ expect(stopped.phase).toBe('STOPPED');
75
+ expect(stopped.stop_reason).toBe('verification_failed');
76
+ });
77
+ it('preserves state when stopping', () => {
78
+ let state = createInitialState({
79
+ run_id: 'stop-preserve',
80
+ repo_path: '/test',
81
+ task_text: 'Test',
82
+ allowlist: ['src/**'],
83
+ denylist: []
84
+ });
85
+ state = updatePhase(state, 'IMPLEMENT');
86
+ state = { ...state, milestone_index: 2 };
87
+ const stopped = stopRun(state, 'complete');
88
+ expect(stopped.milestone_index).toBe(2);
89
+ expect(stopped.scope_lock.allowlist).toEqual(['src/**']);
90
+ });
91
+ });
92
+ describe('phase ordering', () => {
93
+ it('follows expected phase progression for success path', () => {
94
+ const phases = [];
95
+ let state = createInitialState({
96
+ run_id: 'phase-order',
97
+ repo_path: '/test',
98
+ task_text: 'Test',
99
+ allowlist: [],
100
+ denylist: []
101
+ });
102
+ phases.push(state.phase);
103
+ state = updatePhase(state, 'PLAN');
104
+ phases.push(state.phase);
105
+ state = updatePhase(state, 'IMPLEMENT');
106
+ phases.push(state.phase);
107
+ state = updatePhase(state, 'VERIFY');
108
+ phases.push(state.phase);
109
+ state = updatePhase(state, 'REVIEW');
110
+ phases.push(state.phase);
111
+ state = updatePhase(state, 'CHECKPOINT');
112
+ phases.push(state.phase);
113
+ state = updatePhase(state, 'FINALIZE');
114
+ phases.push(state.phase);
115
+ expect(phases).toEqual([
116
+ 'INIT',
117
+ 'PLAN',
118
+ 'IMPLEMENT',
119
+ 'VERIFY',
120
+ 'REVIEW',
121
+ 'CHECKPOINT',
122
+ 'FINALIZE'
123
+ ]);
124
+ });
125
+ it('can loop back to IMPLEMENT after CHECKPOINT for multiple milestones', () => {
126
+ let state = createInitialState({
127
+ run_id: 'loop-test',
128
+ repo_path: '/test',
129
+ task_text: 'Multi milestone task',
130
+ allowlist: [],
131
+ denylist: []
132
+ });
133
+ // First milestone
134
+ state = updatePhase(state, 'PLAN');
135
+ state = updatePhase(state, 'IMPLEMENT');
136
+ state = updatePhase(state, 'VERIFY');
137
+ state = updatePhase(state, 'REVIEW');
138
+ state = updatePhase(state, 'CHECKPOINT');
139
+ state = { ...state, milestone_index: 1 };
140
+ // Second milestone - loops back to IMPLEMENT
141
+ state = updatePhase(state, 'IMPLEMENT');
142
+ expect(state.phase).toBe('IMPLEMENT');
143
+ expect(state.milestone_index).toBe(1);
144
+ state = updatePhase(state, 'VERIFY');
145
+ state = updatePhase(state, 'REVIEW');
146
+ state = updatePhase(state, 'CHECKPOINT');
147
+ state = { ...state, milestone_index: 2 };
148
+ // Finalize after all milestones
149
+ state = updatePhase(state, 'FINALIZE');
150
+ expect(state.phase).toBe('FINALIZE');
151
+ expect(state.milestone_index).toBe(2);
152
+ });
153
+ it('stops on verification failure', () => {
154
+ let state = createInitialState({
155
+ run_id: 'verify-fail',
156
+ repo_path: '/test',
157
+ task_text: 'Test',
158
+ allowlist: [],
159
+ denylist: []
160
+ });
161
+ state = updatePhase(state, 'PLAN');
162
+ state = updatePhase(state, 'IMPLEMENT');
163
+ state = updatePhase(state, 'VERIFY');
164
+ // Verification fails - stop run
165
+ state = stopRun(state, 'verification_failed');
166
+ expect(state.phase).toBe('STOPPED');
167
+ expect(state.stop_reason).toBe('verification_failed');
168
+ // last_successful_phase is set by updatePhase (previous phase), not stopRun
169
+ expect(state.last_successful_phase).toBe('IMPLEMENT');
170
+ });
171
+ });
172
+ describe('computeResumeTargetPhase', () => {
173
+ it('returns current phase if not STOPPED', () => {
174
+ const state = createInitialState({
175
+ run_id: 'test',
176
+ repo_path: '/test',
177
+ task_text: 'Test',
178
+ allowlist: [],
179
+ denylist: []
180
+ });
181
+ const updated = updatePhase(state, 'IMPLEMENT');
182
+ expect(computeResumeTargetPhase(updated)).toBe('IMPLEMENT');
183
+ });
184
+ it('returns next phase after last_successful_phase when STOPPED', () => {
185
+ let state = createInitialState({
186
+ run_id: 'test',
187
+ repo_path: '/test',
188
+ task_text: 'Test',
189
+ allowlist: [],
190
+ denylist: []
191
+ });
192
+ state = updatePhase(state, 'PLAN');
193
+ state = updatePhase(state, 'IMPLEMENT');
194
+ state = updatePhase(state, 'VERIFY');
195
+ state = stopRun(state, 'stalled_timeout');
196
+ // last_successful_phase is IMPLEMENT (from the VERIFY transition)
197
+ expect(state.last_successful_phase).toBe('IMPLEMENT');
198
+ expect(computeResumeTargetPhase(state)).toBe('VERIFY');
199
+ });
200
+ it('returns INIT when STOPPED with no last_successful_phase', () => {
201
+ const state = createInitialState({
202
+ run_id: 'test',
203
+ repo_path: '/test',
204
+ task_text: 'Test',
205
+ allowlist: [],
206
+ denylist: []
207
+ });
208
+ const stopped = stopRun(state, 'guard_violation');
209
+ expect(computeResumeTargetPhase(stopped)).toBe('INIT');
210
+ });
211
+ });
212
+ describe('prepareForResume', () => {
213
+ it('clears stop state and sets resume phase', () => {
214
+ let state = createInitialState({
215
+ run_id: 'test',
216
+ repo_path: '/test',
217
+ task_text: 'Test',
218
+ allowlist: [],
219
+ denylist: []
220
+ });
221
+ // Simulate normal phase progression: INIT -> PLAN -> IMPLEMENT -> VERIFY
222
+ // Each updatePhase sets last_successful_phase to the PREVIOUS phase
223
+ state = updatePhase(state, 'PLAN'); // last_successful_phase = 'INIT'
224
+ state = updatePhase(state, 'IMPLEMENT'); // last_successful_phase = 'PLAN'
225
+ state = updatePhase(state, 'VERIFY'); // last_successful_phase = 'IMPLEMENT'
226
+ state = stopRun(state, 'stalled_timeout');
227
+ state = { ...state, last_error: 'Some error' };
228
+ const resumed = prepareForResume(state);
229
+ // Resume from phase after last_successful_phase ('IMPLEMENT' -> 'VERIFY')
230
+ expect(resumed.phase).toBe('VERIFY');
231
+ expect(resumed.stop_reason).toBeUndefined();
232
+ expect(resumed.last_error).toBeUndefined();
233
+ expect(resumed.resume_token).toBe('test');
234
+ });
235
+ it('increments auto_resume_count when requested', () => {
236
+ let state = createInitialState({
237
+ run_id: 'test',
238
+ repo_path: '/test',
239
+ task_text: 'Test',
240
+ allowlist: [],
241
+ denylist: []
242
+ });
243
+ state = stopRun(state, 'stalled_timeout');
244
+ const resumed = prepareForResume(state, { incrementAutoResumeCount: true });
245
+ expect(resumed.auto_resume_count).toBe(1);
246
+ // Resume again
247
+ const stoppedAgain = stopRun(resumed, 'worker_call_timeout');
248
+ const resumedAgain = prepareForResume(stoppedAgain, { incrementAutoResumeCount: true });
249
+ expect(resumedAgain.auto_resume_count).toBe(2);
250
+ });
251
+ it('preserves auto_resume_count when not incrementing', () => {
252
+ let state = createInitialState({
253
+ run_id: 'test',
254
+ repo_path: '/test',
255
+ task_text: 'Test',
256
+ allowlist: [],
257
+ denylist: []
258
+ });
259
+ state = { ...state, auto_resume_count: 3 };
260
+ state = stopRun(state, 'stalled_timeout');
261
+ const resumed = prepareForResume(state);
262
+ expect(resumed.auto_resume_count).toBe(3);
263
+ });
264
+ it('handles missing auto_resume_count (migration-safe)', () => {
265
+ let state = createInitialState({
266
+ run_id: 'test',
267
+ repo_path: '/test',
268
+ task_text: 'Test',
269
+ allowlist: [],
270
+ denylist: []
271
+ });
272
+ // Simulate old state without auto_resume_count
273
+ delete state.auto_resume_count;
274
+ state = stopRun(state, 'stalled_timeout');
275
+ const resumed = prepareForResume(state, { incrementAutoResumeCount: true });
276
+ expect(resumed.auto_resume_count).toBe(1);
277
+ });
278
+ it('uses custom resumeToken when provided', () => {
279
+ let state = createInitialState({
280
+ run_id: 'original-run',
281
+ repo_path: '/test',
282
+ task_text: 'Test',
283
+ allowlist: [],
284
+ denylist: []
285
+ });
286
+ state = stopRun(state, 'stalled_timeout');
287
+ const resumed = prepareForResume(state, { resumeToken: 'custom-token' });
288
+ expect(resumed.resume_token).toBe('custom-token');
289
+ });
290
+ });