@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.
- package/CHANGELOG.md +150 -1
- package/README.md +124 -111
- package/dist/audit/classifier.js +331 -0
- package/dist/cli.js +593 -282
- package/dist/commands/audit.js +259 -0
- package/dist/commands/bundle.js +180 -0
- package/dist/commands/continue.js +276 -0
- package/dist/commands/doctor.js +430 -45
- package/dist/commands/hooks.js +352 -0
- package/dist/commands/init.js +368 -8
- package/dist/commands/intervene.js +109 -0
- package/dist/commands/journal.js +167 -0
- package/dist/commands/meta.js +245 -0
- package/dist/commands/mode.js +157 -0
- package/dist/commands/orchestrate.js +29 -0
- package/dist/commands/packs.js +47 -0
- package/dist/commands/preflight.js +8 -5
- package/dist/commands/resume.js +421 -3
- package/dist/commands/run.js +63 -4
- package/dist/commands/status.js +47 -0
- package/dist/commands/submit.js +374 -0
- package/dist/config/schema.js +61 -1
- package/dist/diagnosis/analyzer.js +86 -1
- package/dist/diagnosis/formatter.js +3 -0
- package/dist/diagnosis/index.js +1 -0
- package/dist/diagnosis/stop-explainer.js +267 -0
- package/dist/diagnostics/stop-explainer.js +267 -0
- package/dist/guards/checkpoint.js +119 -0
- package/dist/journal/builder.js +497 -0
- package/dist/journal/redactor.js +68 -0
- package/dist/journal/renderer.js +220 -0
- package/dist/journal/types.js +7 -0
- package/dist/orchestrator/artifacts.js +17 -2
- package/dist/orchestrator/receipt.js +304 -0
- package/dist/output/stop-footer.js +185 -0
- package/dist/packs/actions.js +176 -0
- package/dist/packs/loader.js +200 -0
- package/dist/packs/renderer.js +46 -0
- package/dist/receipt/intervention.js +465 -0
- package/dist/receipt/writer.js +296 -0
- package/dist/redaction/redactor.js +95 -0
- package/dist/repo/context.js +147 -20
- package/dist/review/check-parser.js +211 -0
- package/dist/store/checkpoint-metadata.js +111 -0
- package/dist/store/run-store.js +21 -0
- package/dist/supervisor/runner.js +161 -10
- package/dist/tasks/task-metadata.js +74 -1
- package/dist/ux/brain.js +528 -0
- package/dist/ux/render.js +123 -0
- package/dist/ux/safe-commands.js +133 -0
- package/dist/ux/state.js +193 -0
- package/dist/ux/telemetry.js +110 -0
- package/package.json +5 -1
- package/packs/pr/pack.json +50 -0
- package/packs/pr/templates/AGENTS.md.tmpl +120 -0
- package/packs/pr/templates/CLAUDE.md.tmpl +101 -0
- package/packs/pr/templates/bundle.md.tmpl +27 -0
- package/packs/solo/pack.json +82 -0
- package/packs/solo/templates/AGENTS.md.tmpl +80 -0
- package/packs/solo/templates/CLAUDE.md.tmpl +126 -0
- package/packs/solo/templates/bundle.md.tmpl +27 -0
- package/packs/solo/templates/claude-cmd-bundle.md.tmpl +40 -0
- package/packs/solo/templates/claude-cmd-resume.md.tmpl +43 -0
- package/packs/solo/templates/claude-cmd-submit.md.tmpl +51 -0
- package/packs/solo/templates/claude-skill.md.tmpl +96 -0
- package/packs/trunk/pack.json +50 -0
- package/packs/trunk/templates/AGENTS.md.tmpl +87 -0
- package/packs/trunk/templates/CLAUDE.md.tmpl +126 -0
- package/packs/trunk/templates/bundle.md.tmpl +27 -0
- package/dist/commands/__tests__/report.test.js +0 -202
- package/dist/config/__tests__/presets.test.js +0 -104
- package/dist/context/__tests__/artifact.test.js +0 -130
- package/dist/context/__tests__/pack.test.js +0 -191
- package/dist/env/__tests__/fingerprint.test.js +0 -116
- package/dist/orchestrator/__tests__/policy.test.js +0 -185
- package/dist/orchestrator/__tests__/schema-version.test.js +0 -65
- package/dist/supervisor/__tests__/evidence-gate.test.js +0 -111
- package/dist/supervisor/__tests__/ownership.test.js +0 -103
- package/dist/supervisor/__tests__/state-machine.test.js +0 -290
- package/dist/workers/__tests__/claude.test.js +0 -88
- package/dist/workers/__tests__/codex.test.js +0 -81
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,290 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
function extractTextFromClaudeJson(output) {
|
|
3
|
-
try {
|
|
4
|
-
const parsed = JSON.parse(output);
|
|
5
|
-
return parsed.result || parsed.content || parsed.message || output;
|
|
6
|
-
}
|
|
7
|
-
catch {
|
|
8
|
-
// If not valid JSON, return raw output
|
|
9
|
-
return output;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
describe('extractTextFromClaudeJson', () => {
|
|
13
|
-
it('extracts result field from valid JSON', () => {
|
|
14
|
-
const input = '{"result":"Hello World"}';
|
|
15
|
-
expect(extractTextFromClaudeJson(input)).toBe('Hello World');
|
|
16
|
-
});
|
|
17
|
-
it('extracts content field when result is missing', () => {
|
|
18
|
-
const input = '{"content":"Content text","other":"ignored"}';
|
|
19
|
-
expect(extractTextFromClaudeJson(input)).toBe('Content text');
|
|
20
|
-
});
|
|
21
|
-
it('extracts message field as fallback', () => {
|
|
22
|
-
const input = '{"message":"Message text"}';
|
|
23
|
-
expect(extractTextFromClaudeJson(input)).toBe('Message text');
|
|
24
|
-
});
|
|
25
|
-
it('returns raw output when not valid JSON', () => {
|
|
26
|
-
const input = 'This is plain text, not JSON';
|
|
27
|
-
expect(extractTextFromClaudeJson(input)).toBe('This is plain text, not JSON');
|
|
28
|
-
});
|
|
29
|
-
it('returns raw output for truncated JSON', () => {
|
|
30
|
-
const input = '{"result":"trun';
|
|
31
|
-
expect(extractTextFromClaudeJson(input)).toBe('{"result":"trun');
|
|
32
|
-
});
|
|
33
|
-
it('returns raw output for empty object with no known fields', () => {
|
|
34
|
-
const input = '{"unknown":"field"}';
|
|
35
|
-
// Returns the original input since result/content/message are all falsy
|
|
36
|
-
expect(extractTextFromClaudeJson(input)).toBe('{"unknown":"field"}');
|
|
37
|
-
});
|
|
38
|
-
it('handles empty string result', () => {
|
|
39
|
-
const input = '{"result":"","content":"fallback"}';
|
|
40
|
-
// Empty string is falsy, so falls through to content
|
|
41
|
-
expect(extractTextFromClaudeJson(input)).toBe('fallback');
|
|
42
|
-
});
|
|
43
|
-
it('handles null values', () => {
|
|
44
|
-
const input = '{"result":null,"content":"actual content"}';
|
|
45
|
-
expect(extractTextFromClaudeJson(input)).toBe('actual content');
|
|
46
|
-
});
|
|
47
|
-
it('handles nested JSON in result', () => {
|
|
48
|
-
const input = '{"result":"{\\"nested\\":\\"json\\"}"}';
|
|
49
|
-
expect(extractTextFromClaudeJson(input)).toBe('{"nested":"json"}');
|
|
50
|
-
});
|
|
51
|
-
it('handles array response gracefully', () => {
|
|
52
|
-
const input = '["array","response"]';
|
|
53
|
-
// Arrays don't have result/content/message, returns original
|
|
54
|
-
expect(extractTextFromClaudeJson(input)).toBe('["array","response"]');
|
|
55
|
-
});
|
|
56
|
-
it('handles error field in response', () => {
|
|
57
|
-
const input = '{"error":"Something went wrong","result":""}';
|
|
58
|
-
// Empty result is falsy, but we don't currently use error field
|
|
59
|
-
// This returns the raw input since result/content/message are empty/missing
|
|
60
|
-
expect(extractTextFromClaudeJson(input)).toBe('{"error":"Something went wrong","result":""}');
|
|
61
|
-
});
|
|
62
|
-
it('handles whitespace in output', () => {
|
|
63
|
-
const input = ' {"result":"with spaces"} ';
|
|
64
|
-
// JSON.parse handles leading/trailing whitespace
|
|
65
|
-
expect(extractTextFromClaudeJson(input)).toBe('with spaces');
|
|
66
|
-
});
|
|
67
|
-
it('handles newlines in result text', () => {
|
|
68
|
-
const input = '{"result":"line1\\nline2\\nline3"}';
|
|
69
|
-
expect(extractTextFromClaudeJson(input)).toBe('line1\nline2\nline3');
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
describe('Claude JSON error handling', () => {
|
|
73
|
-
it('fails loud on completely invalid input', () => {
|
|
74
|
-
// Current implementation returns the raw output, which is reasonable
|
|
75
|
-
// but we might want to distinguish "valid text" from "parse error" later
|
|
76
|
-
const input = '}{invalid';
|
|
77
|
-
const result = extractTextFromClaudeJson(input);
|
|
78
|
-
// Currently returns raw - this is acceptable behavior
|
|
79
|
-
expect(result).toBe('}{invalid');
|
|
80
|
-
});
|
|
81
|
-
it('handles BOM characters', () => {
|
|
82
|
-
const input = '\uFEFF{"result":"with BOM"}';
|
|
83
|
-
// JSON.parse may fail with BOM prefix
|
|
84
|
-
const result = extractTextFromClaudeJson(input);
|
|
85
|
-
// Should either parse successfully or return raw
|
|
86
|
-
expect(result.includes('with BOM') || result.includes('\uFEFF')).toBe(true);
|
|
87
|
-
});
|
|
88
|
-
});
|