@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,168 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { computeKpiFromEvents } from './report.js';
5
+ import { getRunsRoot } from '../store/runs-root.js';
6
+ export async function compareCommand(options) {
7
+ const result = await loadComparison(options);
8
+ const output = formatComparison(result);
9
+ console.log(output);
10
+ }
11
+ async function loadComparison(options) {
12
+ const kpiA = await loadKpiForRun(options.runA, options.repo);
13
+ const kpiB = await loadKpiForRun(options.runB, options.repo);
14
+ return {
15
+ runA: { id: options.runA, kpi: kpiA },
16
+ runB: { id: options.runB, kpi: kpiB }
17
+ };
18
+ }
19
+ async function loadKpiForRun(runId, repoPath) {
20
+ const runDir = path.join(getRunsRoot(repoPath), runId);
21
+ if (!fs.existsSync(runDir)) {
22
+ throw new Error(`Run not found: ${runDir}`);
23
+ }
24
+ const timelinePath = path.join(runDir, 'timeline.jsonl');
25
+ if (!fs.existsSync(timelinePath)) {
26
+ throw new Error(`Timeline not found: ${timelinePath}`);
27
+ }
28
+ const events = await readTimelineEvents(timelinePath);
29
+ return computeKpiFromEvents(events);
30
+ }
31
+ async function readTimelineEvents(timelinePath) {
32
+ const events = [];
33
+ const stream = fs.createReadStream(timelinePath, { encoding: 'utf-8' });
34
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
+ for await (const line of rl) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed)
38
+ continue;
39
+ try {
40
+ events.push(JSON.parse(trimmed));
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ }
46
+ return events;
47
+ }
48
+ function formatComparison(result) {
49
+ const { runA, runB } = result;
50
+ const lines = [];
51
+ lines.push('Compare');
52
+ lines.push(` A: ${runA.id}`);
53
+ lines.push(` B: ${runB.id}`);
54
+ lines.push('');
55
+ // Duration comparison
56
+ lines.push('Duration');
57
+ const durA = runA.kpi.total_duration_ms;
58
+ const durB = runB.kpi.total_duration_ms;
59
+ lines.push(` A: ${formatDuration(durA)}`);
60
+ lines.push(` B: ${formatDuration(durB)}`);
61
+ lines.push(` Δ: ${formatDelta(durA, durB)}`);
62
+ lines.push('');
63
+ // Unattributed comparison
64
+ lines.push('Unattributed');
65
+ const unA = runA.kpi.unattributed_ms;
66
+ const unB = runB.kpi.unattributed_ms;
67
+ lines.push(` A: ${formatDuration(unA)}`);
68
+ lines.push(` B: ${formatDuration(unB)}`);
69
+ lines.push(` Δ: ${formatDelta(unA, unB)}`);
70
+ lines.push('');
71
+ // Worker calls
72
+ lines.push('Worker Calls');
73
+ lines.push(` A: claude=${runA.kpi.workers.claude} codex=${runA.kpi.workers.codex}`);
74
+ lines.push(` B: claude=${runB.kpi.workers.claude} codex=${runB.kpi.workers.codex}`);
75
+ const claudeDelta = formatWorkerDelta(runA.kpi.workers.claude, runB.kpi.workers.claude);
76
+ const codexDelta = formatWorkerDelta(runA.kpi.workers.codex, runB.kpi.workers.codex);
77
+ lines.push(` Δ: claude=${claudeDelta} codex=${codexDelta}`);
78
+ lines.push('');
79
+ // Verification
80
+ lines.push('Verification');
81
+ lines.push(` A: attempts=${runA.kpi.verify.attempts} retries=${runA.kpi.verify.retries} duration=${formatDuration(runA.kpi.verify.total_duration_ms)}`);
82
+ lines.push(` B: attempts=${runB.kpi.verify.attempts} retries=${runB.kpi.verify.retries} duration=${formatDuration(runB.kpi.verify.total_duration_ms)}`);
83
+ const attemptsDelta = runB.kpi.verify.attempts - runA.kpi.verify.attempts;
84
+ const retriesDelta = runB.kpi.verify.retries - runA.kpi.verify.retries;
85
+ const verifyDurDelta = formatDelta(runA.kpi.verify.total_duration_ms, runB.kpi.verify.total_duration_ms);
86
+ lines.push(` Δ: attempts=${formatNumDelta(attemptsDelta)} retries=${formatNumDelta(retriesDelta)} duration=${verifyDurDelta}`);
87
+ lines.push('');
88
+ // Milestones
89
+ lines.push('Milestones');
90
+ lines.push(` A: ${runA.kpi.milestones.completed}`);
91
+ lines.push(` B: ${runB.kpi.milestones.completed}`);
92
+ const msDelta = runB.kpi.milestones.completed - runA.kpi.milestones.completed;
93
+ lines.push(` Δ: ${formatNumDelta(msDelta)}`);
94
+ lines.push('');
95
+ // Phase comparison
96
+ lines.push('Phases');
97
+ const allPhases = new Set([
98
+ ...Object.keys(runA.kpi.phases),
99
+ ...Object.keys(runB.kpi.phases)
100
+ ]);
101
+ const phaseOrder = ['PLAN', 'IMPLEMENT', 'VERIFY', 'REVIEW', 'CHECKPOINT', 'FINALIZE'];
102
+ const sortedPhases = [...allPhases].sort((a, b) => {
103
+ const aIdx = phaseOrder.indexOf(a);
104
+ const bIdx = phaseOrder.indexOf(b);
105
+ if (aIdx === -1 && bIdx === -1)
106
+ return a.localeCompare(b);
107
+ if (aIdx === -1)
108
+ return 1;
109
+ if (bIdx === -1)
110
+ return -1;
111
+ return aIdx - bIdx;
112
+ });
113
+ for (const phase of sortedPhases) {
114
+ const pA = runA.kpi.phases[phase] ?? { duration_ms: 0, count: 0 };
115
+ const pB = runB.kpi.phases[phase] ?? { duration_ms: 0, count: 0 };
116
+ const durDelta = formatDelta(pA.duration_ms, pB.duration_ms);
117
+ const countDelta = pB.count - pA.count;
118
+ const highlight = pB.duration_ms > pA.duration_ms * 1.2 ? ' ⚠️' : '';
119
+ lines.push(` ${phase}: A=${formatDuration(pA.duration_ms)}(x${pA.count}) B=${formatDuration(pB.duration_ms)}(x${pB.count}) Δ=${durDelta}(${formatNumDelta(countDelta)})${highlight}`);
120
+ }
121
+ lines.push('');
122
+ // Outcome
123
+ lines.push('Outcome');
124
+ lines.push(` A: ${runA.kpi.outcome}${runA.kpi.stop_reason ? ` (${runA.kpi.stop_reason})` : ''}`);
125
+ lines.push(` B: ${runB.kpi.outcome}${runB.kpi.stop_reason ? ` (${runB.kpi.stop_reason})` : ''}`);
126
+ return lines.join('\n');
127
+ }
128
+ function formatDuration(ms) {
129
+ if (ms === null)
130
+ return 'unknown';
131
+ if (ms < 0)
132
+ return `-${formatPositiveDuration(Math.abs(ms))}`;
133
+ return formatPositiveDuration(ms);
134
+ }
135
+ function formatPositiveDuration(ms) {
136
+ if (ms < 1000)
137
+ return `${ms}ms`;
138
+ const seconds = Math.floor(ms / 1000);
139
+ if (seconds < 60)
140
+ return `${seconds}s`;
141
+ const minutes = Math.floor(seconds / 60);
142
+ const remainingSeconds = seconds % 60;
143
+ if (minutes < 60) {
144
+ return remainingSeconds > 0 ? `${minutes}m${remainingSeconds}s` : `${minutes}m`;
145
+ }
146
+ const hours = Math.floor(minutes / 60);
147
+ const remainingMinutes = minutes % 60;
148
+ return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
149
+ }
150
+ function formatDelta(a, b) {
151
+ if (a === null || b === null)
152
+ return 'n/a';
153
+ const diff = b - a;
154
+ if (diff === 0)
155
+ return '0';
156
+ const sign = diff > 0 ? '+' : '-';
157
+ return `${sign}${formatPositiveDuration(Math.abs(diff))}`;
158
+ }
159
+ function formatNumDelta(diff) {
160
+ if (diff === 0)
161
+ return '0';
162
+ return diff > 0 ? `+${diff}` : `${diff}`;
163
+ }
164
+ function formatWorkerDelta(a, b) {
165
+ if (a === 'unknown' || b === 'unknown')
166
+ return 'n/a';
167
+ return formatNumDelta(b - a);
168
+ }
@@ -0,0 +1,124 @@
1
+ import { execa } from 'execa';
2
+ import path from 'node:path';
3
+ import { loadConfig, resolveConfigPath } from '../config/load.js';
4
+ async function checkWorker(name, worker, repoPath) {
5
+ const result = {
6
+ name,
7
+ bin: worker.bin,
8
+ version: null,
9
+ headless: false,
10
+ error: null
11
+ };
12
+ // Check version
13
+ try {
14
+ const versionResult = await execa(worker.bin, ['--version'], {
15
+ timeout: 5000,
16
+ reject: false
17
+ });
18
+ if (versionResult.exitCode === 0) {
19
+ result.version = versionResult.stdout.trim().split('\n')[0];
20
+ }
21
+ else {
22
+ result.error = `Version check failed: ${versionResult.stderr || 'unknown error'}`;
23
+ return result;
24
+ }
25
+ }
26
+ catch (err) {
27
+ result.error = `Command not found: ${worker.bin}`;
28
+ return result;
29
+ }
30
+ // Check headless mode with a simple ping
31
+ try {
32
+ const testPrompt = 'Respond with exactly: PING_OK';
33
+ let testArgs;
34
+ if (name === 'codex') {
35
+ testArgs = ['exec', '--full-auto', '--json', '-C', repoPath];
36
+ }
37
+ else {
38
+ testArgs = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
39
+ }
40
+ const headlessResult = await execa(worker.bin, testArgs, {
41
+ input: testPrompt,
42
+ timeout: 30000,
43
+ reject: false,
44
+ cwd: repoPath
45
+ });
46
+ if (headlessResult.exitCode === 0) {
47
+ result.headless = true;
48
+ }
49
+ else {
50
+ const stderr = headlessResult.stderr || '';
51
+ if (stderr.includes('stdin is not a terminal')) {
52
+ result.error = 'Headless mode not supported (stdin is not a terminal)';
53
+ }
54
+ else {
55
+ result.error = `Headless test failed: ${stderr.slice(0, 100)}`;
56
+ }
57
+ }
58
+ }
59
+ catch (err) {
60
+ result.error = `Headless test error: ${err.message}`;
61
+ }
62
+ return result;
63
+ }
64
+ export async function runDoctorChecks(config, repoPath) {
65
+ const checks = [];
66
+ for (const [name, workerConfig] of Object.entries(config.workers)) {
67
+ const check = await checkWorker(name, workerConfig, repoPath);
68
+ checks.push(check);
69
+ }
70
+ return checks;
71
+ }
72
+ export async function doctorCommand(options) {
73
+ const repoPath = path.resolve(options.repo || '.');
74
+ const configPath = resolveConfigPath(repoPath, options.config);
75
+ console.log('Doctor Check');
76
+ console.log('============\n');
77
+ let config;
78
+ try {
79
+ config = loadConfig(configPath);
80
+ console.log(`Config: ${configPath}`);
81
+ console.log(`Repo: ${repoPath}\n`);
82
+ }
83
+ catch (err) {
84
+ console.log(`Config: FAIL - ${err.message}`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ const checks = await runDoctorChecks(config, repoPath);
89
+ console.log('Workers\n-------');
90
+ for (const check of checks) {
91
+ const status = check.error ? 'FAIL' : 'PASS';
92
+ const version = check.version || 'unknown';
93
+ const headless = check.headless ? 'headless OK' : 'headless FAIL';
94
+ console.log(`${check.name}: ${status}`);
95
+ console.log(` bin: ${check.bin}`);
96
+ console.log(` version: ${version}`);
97
+ console.log(` ${headless}`);
98
+ if (check.error) {
99
+ console.log(` error: ${check.error}`);
100
+ }
101
+ console.log('');
102
+ }
103
+ // Show phase configuration
104
+ console.log('Phases\n------');
105
+ console.log(` plan: ${config.phases.plan}`);
106
+ console.log(` implement: ${config.phases.implement}`);
107
+ console.log(` review: ${config.phases.review}`);
108
+ console.log('');
109
+ // Check that configured phase workers are available
110
+ const phaseWorkers = new Set([config.phases.plan, config.phases.implement, config.phases.review]);
111
+ const failedWorkers = checks.filter((c) => c.error).map((c) => c.name);
112
+ const usedButFailed = [...phaseWorkers].filter((w) => failedWorkers.includes(w));
113
+ const failed = checks.filter((c) => c.error);
114
+ if (failed.length > 0) {
115
+ console.log(`\nResult: ${failed.length} worker(s) failed`);
116
+ if (usedButFailed.length > 0) {
117
+ console.log(`Warning: Phase(s) configured to use failed worker(s): ${usedButFailed.join(', ')}`);
118
+ }
119
+ process.exitCode = 1;
120
+ }
121
+ else {
122
+ console.log('\nResult: All workers OK');
123
+ }
124
+ }
@@ -0,0 +1,251 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { getRunsRoot } from '../store/runs-root.js';
5
+ const TERMINAL_PHASES = ['STOPPED', 'DONE'];
6
+ const POLL_INTERVAL_MS = 1000;
7
+ /**
8
+ * Find the best run to follow: prefer running runs, else latest.
9
+ * Returns { runId, wasRunning } so caller can inform user.
10
+ */
11
+ export function findBestRunToFollow(repoPath) {
12
+ const runsDir = getRunsRoot(repoPath);
13
+ if (!fs.existsSync(runsDir)) {
14
+ return null;
15
+ }
16
+ const runIds = fs
17
+ .readdirSync(runsDir, { withFileTypes: true })
18
+ .filter((e) => e.isDirectory() && /^\d{14}$/.test(e.name))
19
+ .map((e) => e.name)
20
+ .sort()
21
+ .reverse();
22
+ if (runIds.length === 0) {
23
+ return null;
24
+ }
25
+ // Check for a running run (newest first)
26
+ for (const runId of runIds) {
27
+ const statePath = path.join(runsDir, runId, 'state.json');
28
+ if (!fs.existsSync(statePath))
29
+ continue;
30
+ try {
31
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
32
+ if (!TERMINAL_PHASES.includes(state.phase)) {
33
+ return { runId, wasRunning: true };
34
+ }
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ }
40
+ // No running run, return latest
41
+ return { runId: runIds[0], wasRunning: false };
42
+ }
43
+ function formatDuration(ms) {
44
+ if (ms < 1000)
45
+ return `${ms}ms`;
46
+ const seconds = Math.floor(ms / 1000);
47
+ if (seconds < 60)
48
+ return `${seconds}s`;
49
+ const minutes = Math.floor(seconds / 60);
50
+ const remainingSeconds = seconds % 60;
51
+ if (minutes < 60) {
52
+ return remainingSeconds > 0 ? `${minutes}m${remainingSeconds}s` : `${minutes}m`;
53
+ }
54
+ const hours = Math.floor(minutes / 60);
55
+ const remainingMinutes = minutes % 60;
56
+ return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
57
+ }
58
+ function readLastWorkerCall(runDir) {
59
+ const statePath = path.join(runDir, 'state.json');
60
+ if (!fs.existsSync(statePath))
61
+ return null;
62
+ try {
63
+ const content = fs.readFileSync(statePath, 'utf-8');
64
+ const state = JSON.parse(content);
65
+ if (state.last_worker_call && typeof state.last_worker_call === 'object') {
66
+ return state.last_worker_call;
67
+ }
68
+ }
69
+ catch {
70
+ // ignore
71
+ }
72
+ return null;
73
+ }
74
+ function formatEvent(event) {
75
+ const time = new Date(event.timestamp).toLocaleTimeString();
76
+ const prefix = `[${time}] ${event.type}`;
77
+ switch (event.type) {
78
+ case 'run_started':
79
+ return `${prefix} - task: ${event.payload.task}`;
80
+ case 'preflight': {
81
+ const pf = event.payload;
82
+ const guardStatus = pf.guard?.ok ? 'pass' : 'FAIL';
83
+ const pingStatus = pf.ping?.skipped ? 'skipped' : pf.ping?.ok ? 'pass' : 'FAIL';
84
+ return `${prefix} - guard: ${guardStatus}, ping: ${pingStatus}`;
85
+ }
86
+ case 'phase_start':
87
+ return `${prefix} → ${event.payload.phase}`;
88
+ case 'plan_generated': {
89
+ const plan = event.payload;
90
+ const count = plan.milestones?.length ?? 0;
91
+ return `${prefix} - ${count} milestones`;
92
+ }
93
+ case 'implement_complete': {
94
+ const impl = event.payload;
95
+ const files = impl.changed_files?.length ?? 0;
96
+ return `${prefix} - ${files} files changed`;
97
+ }
98
+ case 'review_complete': {
99
+ const review = event.payload;
100
+ return `${prefix} - verdict: ${review.verdict}`;
101
+ }
102
+ case 'tier_passed':
103
+ case 'tier_failed': {
104
+ const tier = event.payload;
105
+ return `${prefix} - ${tier.tier} (${tier.passed ?? 0} passed, ${tier.failed ?? 0} failed)`;
106
+ }
107
+ case 'worker_fallback': {
108
+ const fb = event.payload;
109
+ return `${prefix} - ${fb.from} → ${fb.to} (${fb.reason})`;
110
+ }
111
+ case 'parse_failed': {
112
+ const pf = event.payload;
113
+ return `${prefix} - stage: ${pf.stage}, retry: ${pf.retry_count}`;
114
+ }
115
+ case 'late_worker_result_ignored': {
116
+ const late = event.payload;
117
+ return `${prefix} - ${late.stage} from ${late.worker}`;
118
+ }
119
+ case 'stop': {
120
+ const stop = event.payload;
121
+ const suffix = stop.worker_in_flight ? ' (worker was in-flight)' : '';
122
+ return `${prefix} - reason: ${stop.reason}${suffix}`;
123
+ }
124
+ case 'run_complete': {
125
+ const rc = event.payload;
126
+ return `${prefix} - outcome: ${rc.outcome}`;
127
+ }
128
+ case 'milestone_complete':
129
+ return `${prefix} - milestone ${event.payload.milestone_index}`;
130
+ case 'stalled_timeout': {
131
+ const st = event.payload;
132
+ const sec = st.elapsed_ms ? Math.round(st.elapsed_ms / 1000) : '?';
133
+ return `${prefix} - after ${sec}s`;
134
+ }
135
+ default:
136
+ return prefix;
137
+ }
138
+ }
139
+ async function tailTimeline(timelinePath, fromLine) {
140
+ if (!fs.existsSync(timelinePath)) {
141
+ return { events: [], newLineCount: 0 };
142
+ }
143
+ const events = [];
144
+ const fileStream = fs.createReadStream(timelinePath);
145
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
146
+ let lineNum = 0;
147
+ for await (const line of rl) {
148
+ lineNum++;
149
+ if (lineNum <= fromLine)
150
+ continue;
151
+ if (!line.trim())
152
+ continue;
153
+ try {
154
+ const event = JSON.parse(line);
155
+ events.push(event);
156
+ }
157
+ catch {
158
+ // Skip malformed lines
159
+ }
160
+ }
161
+ return { events, newLineCount: lineNum };
162
+ }
163
+ function readState(statePath) {
164
+ if (!fs.existsSync(statePath)) {
165
+ return null;
166
+ }
167
+ try {
168
+ const content = fs.readFileSync(statePath, 'utf-8');
169
+ return JSON.parse(content);
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ export async function followCommand(options) {
176
+ const runDir = path.join(getRunsRoot(options.repo), options.runId);
177
+ if (!fs.existsSync(runDir)) {
178
+ console.error(`Run directory not found: ${runDir}`);
179
+ process.exitCode = 1;
180
+ return;
181
+ }
182
+ const timelinePath = path.join(runDir, 'timeline.jsonl');
183
+ const statePath = path.join(runDir, 'state.json');
184
+ console.log(`Following run ${options.runId}...`);
185
+ console.log('---');
186
+ let lastLineCount = 0;
187
+ let terminated = false;
188
+ // Initial read of existing events
189
+ const initial = await tailTimeline(timelinePath, 0);
190
+ for (const event of initial.events) {
191
+ console.log(formatEvent(event));
192
+ }
193
+ lastLineCount = initial.newLineCount;
194
+ // Check if already terminated
195
+ const initialState = readState(statePath);
196
+ if (initialState && TERMINAL_PHASES.includes(initialState.phase)) {
197
+ console.log('---');
198
+ console.log(`Run already terminated: ${initialState.phase}`);
199
+ if (initialState.stop_reason) {
200
+ console.log(`Reason: ${initialState.stop_reason}`);
201
+ }
202
+ return;
203
+ }
204
+ // Poll for new events with progress age display
205
+ let lastStatusLine = '';
206
+ while (!terminated) {
207
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
208
+ const update = await tailTimeline(timelinePath, lastLineCount);
209
+ for (const event of update.events) {
210
+ // Clear status line before printing event
211
+ if (lastStatusLine) {
212
+ process.stdout.write('\r' + ' '.repeat(lastStatusLine.length) + '\r');
213
+ lastStatusLine = '';
214
+ }
215
+ console.log(formatEvent(event));
216
+ }
217
+ lastLineCount = update.newLineCount;
218
+ // Check for termination
219
+ const state = readState(statePath);
220
+ if (state && TERMINAL_PHASES.includes(state.phase)) {
221
+ terminated = true;
222
+ if (lastStatusLine) {
223
+ process.stdout.write('\r' + ' '.repeat(lastStatusLine.length) + '\r');
224
+ }
225
+ console.log('---');
226
+ console.log(`Run terminated: ${state.phase}`);
227
+ if (state.stop_reason) {
228
+ console.log(`Reason: ${state.stop_reason}`);
229
+ }
230
+ }
231
+ else if (state) {
232
+ // Show progress age status line
233
+ const progressAge = state.last_progress_at
234
+ ? formatDuration(Date.now() - new Date(state.last_progress_at).getTime())
235
+ : '?';
236
+ const workerCall = readLastWorkerCall(runDir);
237
+ const workerStatus = workerCall
238
+ ? `worker_in_flight=${workerCall.worker}:${workerCall.stage}`
239
+ : 'idle';
240
+ const statusLine = ` [${state.phase}] last progress ${progressAge} ago, ${workerStatus}`;
241
+ // Only update if changed
242
+ if (statusLine !== lastStatusLine) {
243
+ if (lastStatusLine) {
244
+ process.stdout.write('\r' + ' '.repeat(lastStatusLine.length) + '\r');
245
+ }
246
+ process.stdout.write(statusLine);
247
+ lastStatusLine = statusLine;
248
+ }
249
+ }
250
+ }
251
+ }