@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,189 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { RunStore } from '../store/run-store.js';
4
+ import { getRunsRoot } from '../store/runs-root.js';
5
+ import { getActiveRuns, getCollisionRisk } from '../supervisor/collision.js';
6
+ /**
7
+ * Get status of a single run.
8
+ */
9
+ export async function statusCommand(options) {
10
+ const runStore = RunStore.init(options.runId, options.repo);
11
+ const state = runStore.readState();
12
+ console.log(JSON.stringify(state, null, 2));
13
+ }
14
+ /**
15
+ * Get status of all runs in the repo.
16
+ * Displays a table sorted by: running runs first (most recent), then stopped runs (most recent).
17
+ */
18
+ export async function statusAllCommand(options) {
19
+ const runsRoot = getRunsRoot(options.repo);
20
+ if (!fs.existsSync(runsRoot)) {
21
+ console.log('No runs found.');
22
+ return;
23
+ }
24
+ const runDirs = fs.readdirSync(runsRoot, { withFileTypes: true })
25
+ .filter(d => d.isDirectory())
26
+ .map(d => d.name);
27
+ if (runDirs.length === 0) {
28
+ console.log('No runs found.');
29
+ return;
30
+ }
31
+ const summaries = [];
32
+ // Pre-compute active runs for collision detection
33
+ const allActiveRuns = getActiveRuns(options.repo);
34
+ for (const runId of runDirs) {
35
+ const statePath = path.join(runsRoot, runId, 'state.json');
36
+ if (!fs.existsSync(statePath)) {
37
+ continue;
38
+ }
39
+ try {
40
+ const stateRaw = fs.readFileSync(statePath, 'utf-8');
41
+ const state = JSON.parse(stateRaw);
42
+ // Get last worker call info
43
+ const workerCallPath = path.join(runsRoot, runId, 'last_worker_call.json');
44
+ let workerCall = null;
45
+ if (fs.existsSync(workerCallPath)) {
46
+ try {
47
+ workerCall = JSON.parse(fs.readFileSync(workerCallPath, 'utf-8'));
48
+ }
49
+ catch {
50
+ // Ignore parse errors
51
+ }
52
+ }
53
+ const isRunning = state.phase !== 'STOPPED';
54
+ const updatedAt = state.updated_at ? new Date(state.updated_at) : new Date(0);
55
+ const age = formatAge(updatedAt);
56
+ // In-flight worker info
57
+ let inFlight = '-';
58
+ if (isRunning && workerCall) {
59
+ const elapsed = Math.floor((Date.now() - new Date(workerCall.at).getTime()) / 1000);
60
+ inFlight = `${workerCall.worker}/${workerCall.stage} (${elapsed}s)`;
61
+ }
62
+ // Compute collision risk with other active runs (excluding this run)
63
+ // Only show risk for running runs; stopped runs show '-'
64
+ let collisionRisk = '-';
65
+ if (isRunning) {
66
+ collisionRisk = 'none';
67
+ const otherActiveRuns = allActiveRuns.filter(r => r.runId !== runId);
68
+ if (otherActiveRuns.length > 0) {
69
+ // Extract files_expected from milestones
70
+ const touchFiles = [];
71
+ for (const milestone of state.milestones) {
72
+ if (milestone.files_expected) {
73
+ touchFiles.push(...milestone.files_expected);
74
+ }
75
+ }
76
+ collisionRisk = getCollisionRisk(state.scope_lock?.allowlist ?? [], touchFiles, otherActiveRuns);
77
+ }
78
+ }
79
+ summaries.push({
80
+ runId,
81
+ status: isRunning ? 'running' : 'stopped',
82
+ phase: state.phase,
83
+ milestones: `${state.milestone_index + 1}/${state.milestones.length}`,
84
+ age,
85
+ stopReason: state.stop_reason ?? '-',
86
+ autoResumeCount: state.auto_resume_count ?? 0,
87
+ inFlight,
88
+ collisionRisk,
89
+ updatedAt
90
+ });
91
+ }
92
+ catch {
93
+ // Skip runs with invalid state
94
+ }
95
+ }
96
+ if (summaries.length === 0) {
97
+ console.log('No valid runs found.');
98
+ return;
99
+ }
100
+ // Sort: running first (most recent), then stopped (most recent)
101
+ summaries.sort((a, b) => {
102
+ if (a.status !== b.status) {
103
+ return a.status === 'running' ? -1 : 1;
104
+ }
105
+ return b.updatedAt.getTime() - a.updatedAt.getTime();
106
+ });
107
+ // Print table
108
+ printTable(summaries);
109
+ }
110
+ /**
111
+ * Format age as human-readable string.
112
+ */
113
+ function formatAge(date) {
114
+ const now = Date.now();
115
+ const diffMs = now - date.getTime();
116
+ if (diffMs < 0)
117
+ return 'future';
118
+ const seconds = Math.floor(diffMs / 1000);
119
+ if (seconds < 60)
120
+ return `${seconds}s`;
121
+ const minutes = Math.floor(seconds / 60);
122
+ if (minutes < 60)
123
+ return `${minutes}m`;
124
+ const hours = Math.floor(minutes / 60);
125
+ if (hours < 24)
126
+ return `${hours}h`;
127
+ const days = Math.floor(hours / 24);
128
+ return `${days}d`;
129
+ }
130
+ /**
131
+ * Print formatted table of run summaries.
132
+ */
133
+ function printTable(summaries) {
134
+ // Column headers
135
+ const headers = ['RUN ID', 'STATUS', 'PHASE', 'PROGRESS', 'AGE', 'STOP REASON', 'RESUMES', 'RISK', 'IN-FLIGHT'];
136
+ // Calculate column widths
137
+ const widths = headers.map((h, i) => {
138
+ const values = summaries.map(s => {
139
+ switch (i) {
140
+ case 0: return s.runId;
141
+ case 1: return s.status;
142
+ case 2: return s.phase;
143
+ case 3: return s.milestones;
144
+ case 4: return s.age;
145
+ case 5: return s.stopReason;
146
+ case 6: return String(s.autoResumeCount);
147
+ case 7: return s.collisionRisk;
148
+ case 8: return s.inFlight;
149
+ default: return '';
150
+ }
151
+ });
152
+ return Math.max(h.length, ...values.map(v => v.length));
153
+ });
154
+ // Print header
155
+ const headerLine = headers.map((h, i) => h.padEnd(widths[i])).join(' ');
156
+ console.log(headerLine);
157
+ console.log('-'.repeat(headerLine.length));
158
+ // Print rows
159
+ for (const s of summaries) {
160
+ const row = [
161
+ s.runId.padEnd(widths[0]),
162
+ s.status.padEnd(widths[1]),
163
+ s.phase.padEnd(widths[2]),
164
+ s.milestones.padEnd(widths[3]),
165
+ s.age.padEnd(widths[4]),
166
+ s.stopReason.padEnd(widths[5]),
167
+ String(s.autoResumeCount).padEnd(widths[6]),
168
+ s.collisionRisk.padEnd(widths[7]),
169
+ s.inFlight.padEnd(widths[8])
170
+ ];
171
+ console.log(row.join(' '));
172
+ }
173
+ // Print summary
174
+ const running = summaries.filter(s => s.status === 'running').length;
175
+ const stopped = summaries.filter(s => s.status === 'stopped').length;
176
+ const allowlistRisk = summaries.filter(s => s.collisionRisk === 'allowlist').length;
177
+ const collisionRisk = summaries.filter(s => s.collisionRisk === 'collision').length;
178
+ console.log('');
179
+ let summaryLine = `Total: ${summaries.length} runs (${running} running, ${stopped} stopped)`;
180
+ if (allowlistRisk > 0 || collisionRisk > 0) {
181
+ const riskParts = [];
182
+ if (allowlistRisk > 0)
183
+ riskParts.push(`${allowlistRisk} allowlist`);
184
+ if (collisionRisk > 0)
185
+ riskParts.push(`${collisionRisk} collision`);
186
+ summaryLine += ` — risk: ${riskParts.join(', ')}`;
187
+ }
188
+ console.log(summaryLine);
189
+ }
@@ -0,0 +1,220 @@
1
+ import { RunStore } from '../store/run-store.js';
2
+ import { resolveRunId } from '../store/run-utils.js';
3
+ import { getRunsRoot } from '../store/runs-root.js';
4
+ import { computeKpiFromEvents } from './report.js';
5
+ import { diagnoseStop, formatStopMarkdown } from '../diagnosis/index.js';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import readline from 'node:readline';
9
+ /**
10
+ * Generate a machine-readable JSON summary of a run.
11
+ * Outputs compact JSON to stdout for piping/parsing.
12
+ * Supports 'latest' as runId to resolve to most recent run.
13
+ */
14
+ export async function summarizeCommand(options) {
15
+ // Resolve 'latest' to actual run ID and validate existence
16
+ const resolvedRunId = resolveRunId(options.runId, options.repo);
17
+ const runDir = path.join(getRunsRoot(options.repo), resolvedRunId);
18
+ const runStore = RunStore.init(resolvedRunId, options.repo);
19
+ const state = runStore.readState();
20
+ // Read timeline events for KPI computation
21
+ const timelinePath = path.join(runDir, 'timeline.jsonl');
22
+ const events = await readTimelineEvents(timelinePath);
23
+ const kpi = computeKpiFromEvents(events);
24
+ // Read config.snapshot.json for worktree_enabled and other config
25
+ const configSnapshot = readConfigSnapshot(runDir);
26
+ // Extract config flags from run_started event
27
+ const runStartedEvent = events.find((e) => e.type === 'run_started');
28
+ const configPayload = extractConfigPayload(runStartedEvent, configSnapshot);
29
+ // Get total milestones from state if available
30
+ const totalMilestones = state.milestones?.length ?? null;
31
+ // Extract ticks info from timeline events (sum across all sessions including resumes)
32
+ const ticksInfo = extractTicksInfo(events, state);
33
+ // Generate diagnosis for stopped runs (not for successful completions)
34
+ let diagnosisResult;
35
+ const isFailedStop = state.phase === 'STOPPED' && state.stop_reason !== 'complete';
36
+ if (isFailedStop) {
37
+ const diagnosisContext = {
38
+ runId: resolvedRunId,
39
+ runDir,
40
+ state: {
41
+ phase: state.phase,
42
+ stop_reason: state.stop_reason,
43
+ milestone_index: state.milestone_index,
44
+ milestones_total: state.milestones?.length ?? 0,
45
+ last_error: state.last_error
46
+ },
47
+ events,
48
+ configSnapshot: configSnapshot
49
+ };
50
+ diagnosisResult = diagnoseStop(diagnosisContext);
51
+ // Write diagnosis artifacts to handoffs directory
52
+ const handoffsDir = path.join(runDir, 'handoffs');
53
+ if (!fs.existsSync(handoffsDir)) {
54
+ fs.mkdirSync(handoffsDir, { recursive: true });
55
+ }
56
+ // Write stop.json
57
+ const stopJsonPath = path.join(handoffsDir, 'stop.json');
58
+ fs.writeFileSync(stopJsonPath, JSON.stringify(diagnosisResult, null, 2) + '\n', 'utf-8');
59
+ // Write stop.md
60
+ const stopMdPath = path.join(handoffsDir, 'stop.md');
61
+ const stopMd = formatStopMarkdown(diagnosisResult);
62
+ fs.writeFileSync(stopMdPath, stopMd + '\n', 'utf-8');
63
+ }
64
+ const summary = {
65
+ run_id: resolvedRunId,
66
+ outcome: kpi.outcome,
67
+ stop_reason: kpi.stop_reason,
68
+ duration_seconds: msToSeconds(kpi.total_duration_ms),
69
+ milestones: {
70
+ completed: kpi.milestones.completed,
71
+ total: totalMilestones
72
+ },
73
+ worker_calls: {
74
+ claude: kpi.workers.claude,
75
+ codex: kpi.workers.codex
76
+ },
77
+ verification: {
78
+ attempts: kpi.verify.attempts,
79
+ retries: kpi.verify.retries,
80
+ duration_seconds: msToSeconds(kpi.verify.total_duration_ms) ?? 0
81
+ },
82
+ reliability: {
83
+ infra_retries: kpi.reliability.infra_retries,
84
+ fallback_used: kpi.reliability.fallback_used,
85
+ fallback_count: kpi.reliability.fallback_count,
86
+ stalls_triggered: kpi.reliability.stalls_triggered,
87
+ late_results_ignored: kpi.reliability.late_results_ignored,
88
+ max_ticks_hit: ticksInfo.max_ticks_hit,
89
+ ticks_used: ticksInfo.ticks_used
90
+ },
91
+ config: configPayload,
92
+ timestamps: {
93
+ started_at: kpi.started_at,
94
+ ended_at: kpi.ended_at
95
+ },
96
+ diagnosis: diagnosisResult
97
+ ? {
98
+ primary: diagnosisResult.primary_diagnosis,
99
+ confidence: diagnosisResult.confidence,
100
+ next_action: diagnosisResult.next_actions[0]?.command
101
+ }
102
+ : undefined
103
+ };
104
+ // Write summary.json to run directory (idempotent - overwrites if exists)
105
+ const summaryPath = path.join(runDir, 'summary.json');
106
+ const formattedJson = JSON.stringify(summary, null, 2);
107
+ fs.writeFileSync(summaryPath, formattedJson + '\n', 'utf-8');
108
+ console.log(`Summary written to ${summaryPath}`);
109
+ // Log diagnosis info if available
110
+ if (diagnosisResult) {
111
+ const handoffsDir = path.join(runDir, 'handoffs');
112
+ console.log(`Diagnosis written to ${path.join(handoffsDir, 'stop.json')} and stop.md`);
113
+ console.log(` Primary: ${diagnosisResult.primary_diagnosis} (${Math.round(diagnosisResult.confidence * 100)}%)`);
114
+ if (diagnosisResult.next_actions[0]) {
115
+ console.log(` Next: ${diagnosisResult.next_actions[0].title}`);
116
+ }
117
+ }
118
+ }
119
+ async function readTimelineEvents(timelinePath) {
120
+ if (!fs.existsSync(timelinePath)) {
121
+ return [];
122
+ }
123
+ const events = [];
124
+ const stream = fs.createReadStream(timelinePath, { encoding: 'utf-8' });
125
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
126
+ for await (const line of rl) {
127
+ const trimmed = line.trim();
128
+ if (!trimmed)
129
+ continue;
130
+ try {
131
+ const event = JSON.parse(trimmed);
132
+ events.push(event);
133
+ }
134
+ catch {
135
+ // Skip malformed lines
136
+ }
137
+ }
138
+ return events;
139
+ }
140
+ function readConfigSnapshot(runDir) {
141
+ const snapshotPath = path.join(runDir, 'config.snapshot.json');
142
+ // Handle missing config.snapshot.json gracefully with defaults
143
+ if (!fs.existsSync(snapshotPath)) {
144
+ return {
145
+ worktree_enabled: false
146
+ };
147
+ }
148
+ try {
149
+ const raw = fs.readFileSync(snapshotPath, 'utf-8');
150
+ const parsed = JSON.parse(raw);
151
+ // worktree_enabled is determined by presence of _worktree field
152
+ const worktreeEnabled = parsed._worktree !== undefined && parsed._worktree !== null;
153
+ return {
154
+ worktree_enabled: worktreeEnabled
155
+ };
156
+ }
157
+ catch {
158
+ // Handle parse errors gracefully with defaults
159
+ return {
160
+ worktree_enabled: false
161
+ };
162
+ }
163
+ }
164
+ function extractConfigPayload(runStartedEvent, configSnapshot) {
165
+ const defaults = {
166
+ dry_run: null,
167
+ no_branch: null,
168
+ allow_dirty: null,
169
+ allow_deps: null,
170
+ worktree_enabled: configSnapshot?.worktree_enabled ?? false,
171
+ time_budget_minutes: null,
172
+ max_ticks: null
173
+ };
174
+ if (!runStartedEvent?.payload || typeof runStartedEvent.payload !== 'object') {
175
+ return defaults;
176
+ }
177
+ const payload = runStartedEvent.payload;
178
+ return {
179
+ dry_run: payload.dry_run ?? null,
180
+ no_branch: payload.no_branch ?? null,
181
+ allow_dirty: payload.allow_dirty ?? null,
182
+ allow_deps: payload.allow_deps ?? null,
183
+ worktree_enabled: configSnapshot?.worktree_enabled ?? false,
184
+ time_budget_minutes: payload.time_budget_minutes ?? null,
185
+ max_ticks: payload.max_ticks ?? null
186
+ };
187
+ }
188
+ function msToSeconds(ms) {
189
+ if (ms === null)
190
+ return null;
191
+ return Math.round(ms / 1000);
192
+ }
193
+ /**
194
+ * Extract ticks info from timeline events and state.
195
+ * Sums ticks_used across all sessions (including resumes).
196
+ */
197
+ function extractTicksInfo(events, state) {
198
+ // Check if stop_reason indicates max_ticks_reached
199
+ const stopReason = state.stop_reason;
200
+ const maxTicksHit = stopReason === 'max_ticks_reached';
201
+ // Sum ticks_used from max_ticks_reached events and stop events with ticks_used
202
+ let totalTicks = 0;
203
+ for (const event of events) {
204
+ if (event.type === 'max_ticks_reached' || event.type === 'stop') {
205
+ const payload = event.payload;
206
+ const ticksUsed = payload?.ticks_used;
207
+ if (typeof ticksUsed === 'number') {
208
+ totalTicks += ticksUsed;
209
+ }
210
+ }
211
+ }
212
+ // If no ticks_used found in events, estimate from phase_start count
213
+ if (totalTicks === 0) {
214
+ totalTicks = events.filter((e) => e.type === 'phase_start').length;
215
+ }
216
+ return {
217
+ max_ticks_hit: maxTicksHit,
218
+ ticks_used: totalTicks
219
+ };
220
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Version command.
3
+ *
4
+ * Outputs version information in JSON or human-readable format.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { execSync } from 'node:child_process';
10
+ // Get agent version from package.json
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const packageJsonPath = path.resolve(__dirname, '../../package.json');
13
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
14
+ const AGENT_VERSION = packageJson.version;
15
+ /**
16
+ * Current artifact schema version.
17
+ * This should match the schema_version stamped into all artifacts.
18
+ */
19
+ export const ARTIFACT_SCHEMA_VERSION = 1;
20
+ /**
21
+ * Get the current git commit hash (short form).
22
+ * Returns null if not in a git repo or git not available.
23
+ */
24
+ function getGitCommit() {
25
+ // Check CI environment variables first
26
+ const ciCommit = process.env.GITHUB_SHA
27
+ || process.env.CI_COMMIT_SHA
28
+ || process.env.GIT_COMMIT;
29
+ if (ciCommit) {
30
+ return ciCommit.slice(0, 7);
31
+ }
32
+ // Try git command (best-effort, non-fatal)
33
+ try {
34
+ const commit = execSync('git rev-parse --short HEAD', {
35
+ encoding: 'utf-8',
36
+ stdio: ['ignore', 'pipe', 'ignore']
37
+ }).trim();
38
+ return commit || null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Build version output object.
46
+ */
47
+ export function getVersionInfo() {
48
+ return {
49
+ schema_version: 1,
50
+ agent_version: AGENT_VERSION,
51
+ artifact_schema_version: ARTIFACT_SCHEMA_VERSION,
52
+ node: process.version,
53
+ platform: process.platform,
54
+ commit: getGitCommit()
55
+ };
56
+ }
57
+ /**
58
+ * Format version for human-readable output.
59
+ */
60
+ function formatVersion(info) {
61
+ const lines = [];
62
+ lines.push(`Agent Runner v${info.agent_version}`);
63
+ lines.push(` Artifact Schema: v${info.artifact_schema_version}`);
64
+ lines.push(` Node: ${info.node}`);
65
+ lines.push(` Platform: ${info.platform}`);
66
+ if (info.commit) {
67
+ lines.push(` Commit: ${info.commit}`);
68
+ }
69
+ return lines.join('\n');
70
+ }
71
+ /**
72
+ * Run the version command.
73
+ */
74
+ export async function versionCommand(options) {
75
+ const info = getVersionInfo();
76
+ if (options.json) {
77
+ console.log(JSON.stringify(info, null, 2));
78
+ }
79
+ else {
80
+ console.log(formatVersion(info));
81
+ }
82
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * agent wait - Block until run reaches terminal state.
3
+ *
4
+ * Designed for meta-agent coordination. Returns machine-readable JSON
5
+ * with run outcome, suitable for scripting and automation.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { getRunsRoot } from '../store/runs-root.js';
10
+ /**
11
+ * Current schema version for WaitResult.
12
+ * Increment when making breaking changes to the structure.
13
+ */
14
+ export const WAIT_RESULT_SCHEMA_VERSION = 1;
15
+ const TERMINAL_PHASES = ['STOPPED', 'DONE'];
16
+ const POLL_INTERVAL_MS = 500;
17
+ const BACKOFF_MAX_MS = 2000;
18
+ function readState(statePath) {
19
+ if (!fs.existsSync(statePath)) {
20
+ return null;
21
+ }
22
+ try {
23
+ const content = fs.readFileSync(statePath, 'utf-8');
24
+ return JSON.parse(content);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function isTerminal(state) {
31
+ return TERMINAL_PHASES.includes(state.phase);
32
+ }
33
+ function matchesCondition(state, condition) {
34
+ if (!isTerminal(state))
35
+ return false;
36
+ switch (condition) {
37
+ case 'terminal':
38
+ return true;
39
+ case 'complete':
40
+ return state.stop_reason === 'complete';
41
+ case 'stop':
42
+ return state.stop_reason !== 'complete';
43
+ default:
44
+ return true;
45
+ }
46
+ }
47
+ function buildResult(runId, runDir, repoRoot, state, elapsedMs, timedOut) {
48
+ const isComplete = state.stop_reason === 'complete';
49
+ const result = {
50
+ schema_version: WAIT_RESULT_SCHEMA_VERSION,
51
+ run_id: runId,
52
+ run_dir: runDir,
53
+ repo_root: repoRoot,
54
+ status: timedOut ? 'timeout' : isComplete ? 'complete' : 'stopped',
55
+ phase: state.phase,
56
+ progress: {
57
+ milestone: state.milestone_index + 1,
58
+ of: state.milestones.length
59
+ },
60
+ elapsed_ms: elapsedMs,
61
+ ts: new Date().toISOString()
62
+ };
63
+ if (state.stop_reason && state.stop_reason !== 'complete') {
64
+ result.stop_reason = state.stop_reason;
65
+ }
66
+ // Add resume command for non-complete stops
67
+ if (!isComplete && !timedOut) {
68
+ result.resume_command = `agent resume ${runId}`;
69
+ }
70
+ // Add collision info if relevant
71
+ if (state.stop_reason === 'parallel_file_collision') {
72
+ // Could extract from timeline, but for now just flag it
73
+ result.collision_info = {};
74
+ }
75
+ return result;
76
+ }
77
+ function sleep(ms) {
78
+ return new Promise(resolve => setTimeout(resolve, ms));
79
+ }
80
+ export async function waitCommand(options) {
81
+ const runDir = path.join(getRunsRoot(options.repo), options.runId);
82
+ const statePath = path.join(runDir, 'state.json');
83
+ if (!fs.existsSync(runDir)) {
84
+ if (options.json) {
85
+ console.log(JSON.stringify({
86
+ error: 'run_not_found',
87
+ run_id: options.runId,
88
+ message: `Run directory not found: ${runDir}`
89
+ }));
90
+ }
91
+ else {
92
+ console.error(`Run directory not found: ${runDir}`);
93
+ }
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ const repoRoot = path.resolve(options.repo);
98
+ const startTime = Date.now();
99
+ const timeoutMs = options.timeout ?? Infinity;
100
+ let pollInterval = POLL_INTERVAL_MS;
101
+ let lastState = null;
102
+ while (true) {
103
+ const elapsed = Date.now() - startTime;
104
+ // Check timeout
105
+ if (elapsed >= timeoutMs) {
106
+ const state = readState(statePath);
107
+ if (state) {
108
+ const result = buildResult(options.runId, runDir, repoRoot, state, elapsed, true);
109
+ if (options.json) {
110
+ console.log(JSON.stringify(result, null, 2));
111
+ }
112
+ else {
113
+ console.log(`Timeout after ${Math.round(elapsed / 1000)}s`);
114
+ console.log(`Current phase: ${state.phase}`);
115
+ console.log(`Progress: ${state.milestone_index + 1}/${state.milestones.length}`);
116
+ }
117
+ }
118
+ process.exitCode = 124; // timeout exit code (like GNU timeout)
119
+ return;
120
+ }
121
+ // Read current state
122
+ const state = readState(statePath);
123
+ if (!state) {
124
+ await sleep(pollInterval);
125
+ continue;
126
+ }
127
+ lastState = state;
128
+ // Check if condition is met
129
+ if (matchesCondition(state, options.for)) {
130
+ const result = buildResult(options.runId, runDir, repoRoot, state, elapsed, false);
131
+ if (options.json) {
132
+ console.log(JSON.stringify(result, null, 2));
133
+ }
134
+ else {
135
+ const statusWord = result.status === 'complete' ? 'Completed' : 'Stopped';
136
+ console.log(`${statusWord} after ${Math.round(elapsed / 1000)}s`);
137
+ console.log(`Phase: ${state.phase}`);
138
+ console.log(`Progress: ${result.progress.milestone}/${result.progress.of}`);
139
+ if (result.stop_reason) {
140
+ console.log(`Reason: ${result.stop_reason}`);
141
+ }
142
+ if (result.resume_command) {
143
+ console.log(`Resume: ${result.resume_command}`);
144
+ }
145
+ }
146
+ // Exit code: 0 for complete, 1 for stop
147
+ process.exitCode = result.status === 'complete' ? 0 : 1;
148
+ return;
149
+ }
150
+ // Backoff polling interval
151
+ pollInterval = Math.min(pollInterval * 1.2, BACKOFF_MAX_MS);
152
+ await sleep(pollInterval);
153
+ }
154
+ }
155
+ /**
156
+ * Find the latest run ID for --latest flag.
157
+ */
158
+ export function findLatestRunId(repoPath) {
159
+ const runsDir = getRunsRoot(repoPath);
160
+ if (!fs.existsSync(runsDir)) {
161
+ return null;
162
+ }
163
+ const runIds = fs
164
+ .readdirSync(runsDir, { withFileTypes: true })
165
+ .filter((e) => e.isDirectory() && /^\d{14}$/.test(e.name))
166
+ .map((e) => e.name)
167
+ .sort()
168
+ .reverse();
169
+ return runIds[0] ?? null;
170
+ }