@weldr/runr 0.3.0 → 0.4.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.
@@ -0,0 +1,440 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Detect Python project verification commands
5
+ */
6
+ function detectPythonVerification(repoPath) {
7
+ const hasPyprojectToml = fs.existsSync(path.join(repoPath, 'pyproject.toml'));
8
+ const hasPytestIni = fs.existsSync(path.join(repoPath, 'pytest.ini'));
9
+ const hasPoetryLock = fs.existsSync(path.join(repoPath, 'poetry.lock'));
10
+ // If no Python markers, return null
11
+ if (!hasPyprojectToml && !hasPytestIni && !hasPoetryLock) {
12
+ return null;
13
+ }
14
+ const verification = {
15
+ tier0: [],
16
+ tier1: [],
17
+ tier2: []
18
+ };
19
+ const presets = [];
20
+ // Parse pyproject.toml if it exists
21
+ let pyprojectContent = null;
22
+ if (hasPyprojectToml) {
23
+ try {
24
+ const pyprojectPath = path.join(repoPath, 'pyproject.toml');
25
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
26
+ // Simple TOML parsing for common sections
27
+ // Look for [tool.poetry], [tool.pytest], etc.
28
+ if (content.includes('[tool.poetry]') || hasPoetryLock) {
29
+ presets.push('poetry');
30
+ // Tier 1: Poetry install/check
31
+ verification.tier1.push('poetry check');
32
+ }
33
+ if (content.includes('[tool.pytest]') || hasPytestIni) {
34
+ presets.push('pytest');
35
+ // Tier 2: Run tests
36
+ verification.tier2.push('pytest');
37
+ }
38
+ // Check for mypy, black, ruff, etc.
39
+ if (content.includes('[tool.mypy]') || content.includes('mypy')) {
40
+ verification.tier0.push('mypy .');
41
+ }
42
+ if (content.includes('[tool.black]') || content.includes('black')) {
43
+ verification.tier0.push('black --check .');
44
+ }
45
+ if (content.includes('[tool.ruff]') || content.includes('ruff')) {
46
+ verification.tier0.push('ruff check .');
47
+ }
48
+ }
49
+ catch {
50
+ // If parsing fails, continue with basic detection
51
+ }
52
+ }
53
+ // If pytest.ini exists but not already detected
54
+ if (hasPytestIni && !verification.tier2.includes('pytest')) {
55
+ presets.push('pytest');
56
+ verification.tier2.push('pytest');
57
+ }
58
+ // If nothing was detected, return null
59
+ if (verification.tier0.length === 0 && verification.tier1.length === 0 && verification.tier2.length === 0) {
60
+ return null;
61
+ }
62
+ return {
63
+ verification,
64
+ presets,
65
+ source: 'python'
66
+ };
67
+ }
68
+ /**
69
+ * Detect verification commands from package.json scripts
70
+ */
71
+ function detectFromPackageJson(repoPath) {
72
+ const packageJsonPath = path.join(repoPath, 'package.json');
73
+ if (!fs.existsSync(packageJsonPath)) {
74
+ return null;
75
+ }
76
+ try {
77
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
78
+ const scripts = packageJson.scripts || {};
79
+ const verification = {
80
+ tier0: [],
81
+ tier1: [],
82
+ tier2: []
83
+ };
84
+ const presets = [];
85
+ // Tier 0: fast checks (lint, typecheck)
86
+ if (scripts.typecheck) {
87
+ verification.tier0.push('npm run typecheck');
88
+ }
89
+ else if (scripts.tsc || scripts['type-check']) {
90
+ verification.tier0.push(`npm run ${scripts.tsc ? 'tsc' : 'type-check'}`);
91
+ }
92
+ if (scripts.lint) {
93
+ verification.tier0.push('npm run lint');
94
+ }
95
+ else if (scripts.eslint) {
96
+ verification.tier0.push('npm run eslint');
97
+ }
98
+ // Tier 1: build (slower, but catches integration issues)
99
+ if (scripts.build) {
100
+ verification.tier1.push('npm run build');
101
+ }
102
+ else if (scripts.compile) {
103
+ verification.tier1.push('npm run compile');
104
+ }
105
+ // Tier 2: tests (slowest, most comprehensive)
106
+ if (scripts.test) {
107
+ verification.tier2.push('npm run test');
108
+ }
109
+ else if (scripts.jest || scripts.vitest) {
110
+ verification.tier2.push(`npm run ${scripts.jest ? 'jest' : 'vitest'}`);
111
+ }
112
+ // Detect presets from dependencies
113
+ const allDeps = {
114
+ ...packageJson.dependencies,
115
+ ...packageJson.devDependencies
116
+ };
117
+ if (allDeps.typescript)
118
+ presets.push('typescript');
119
+ if (allDeps.vitest)
120
+ presets.push('vitest');
121
+ if (allDeps.jest)
122
+ presets.push('jest');
123
+ if (allDeps.next)
124
+ presets.push('nextjs');
125
+ if (allDeps.react && !allDeps.next)
126
+ presets.push('react');
127
+ if (allDeps.drizzle)
128
+ presets.push('drizzle');
129
+ if (allDeps.prisma || allDeps['@prisma/client'])
130
+ presets.push('prisma');
131
+ if (allDeps.playwright || allDeps['@playwright/test'])
132
+ presets.push('playwright');
133
+ if (allDeps.tailwindcss)
134
+ presets.push('tailwind');
135
+ if (allDeps.eslint)
136
+ presets.push('eslint');
137
+ // If nothing detected, return null
138
+ if (verification.tier0.length === 0 && verification.tier1.length === 0 && verification.tier2.length === 0) {
139
+ return null;
140
+ }
141
+ return {
142
+ verification,
143
+ presets,
144
+ source: 'package.json'
145
+ };
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ /**
152
+ * Generate default config when auto-detection fails
153
+ */
154
+ function generateDefaultConfig(repoPath) {
155
+ const hasSrc = fs.existsSync(path.join(repoPath, 'src'));
156
+ const hasTests = fs.existsSync(path.join(repoPath, 'tests')) ||
157
+ fs.existsSync(path.join(repoPath, 'test'));
158
+ const presets = [];
159
+ // Check for common config files
160
+ if (fs.existsSync(path.join(repoPath, 'tsconfig.json'))) {
161
+ presets.push('typescript');
162
+ }
163
+ // Determine why we couldn't detect verification
164
+ let reason = 'no-package-json';
165
+ const packageJsonPath = path.join(repoPath, 'package.json');
166
+ if (fs.existsSync(packageJsonPath)) {
167
+ try {
168
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
169
+ if (packageJson.scripts && Object.keys(packageJson.scripts).length > 0) {
170
+ reason = 'no-matching-scripts';
171
+ }
172
+ else {
173
+ reason = 'empty-scripts';
174
+ }
175
+ }
176
+ catch {
177
+ reason = 'invalid-package-json';
178
+ }
179
+ }
180
+ return {
181
+ verification: {
182
+ tier0: [],
183
+ tier1: [],
184
+ tier2: []
185
+ },
186
+ presets,
187
+ source: 'none',
188
+ reason
189
+ };
190
+ }
191
+ /**
192
+ * Build config object from detection results
193
+ */
194
+ function buildConfig(repoPath, detection) {
195
+ const hasSrc = fs.existsSync(path.join(repoPath, 'src'));
196
+ const hasTests = fs.existsSync(path.join(repoPath, 'tests')) ||
197
+ fs.existsSync(path.join(repoPath, 'test'));
198
+ // Build allowlist based on directory structure
199
+ const allowlist = [];
200
+ if (hasSrc)
201
+ allowlist.push('src/**');
202
+ if (hasTests) {
203
+ allowlist.push('tests/**');
204
+ allowlist.push('test/**');
205
+ }
206
+ if (allowlist.length === 0) {
207
+ // Default: allow everything except common excludes
208
+ allowlist.push('**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx');
209
+ }
210
+ return {
211
+ agent: {
212
+ name: path.basename(repoPath),
213
+ version: '1'
214
+ },
215
+ scope: {
216
+ allowlist,
217
+ denylist: ['node_modules/**', '.env'],
218
+ lockfiles: ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'],
219
+ presets: detection.presets,
220
+ env_allowlist: [
221
+ 'node_modules',
222
+ 'node_modules/**',
223
+ '.next/**',
224
+ 'dist/**',
225
+ 'build/**',
226
+ '.turbo/**',
227
+ '.eslintcache',
228
+ 'coverage/**'
229
+ ]
230
+ },
231
+ verification: {
232
+ tier0: detection.verification.tier0,
233
+ tier1: detection.verification.tier1,
234
+ tier2: detection.verification.tier2,
235
+ risk_triggers: [],
236
+ max_verify_time_per_milestone: 600
237
+ },
238
+ repo: {},
239
+ workers: {
240
+ codex: {
241
+ bin: 'codex',
242
+ args: ['exec', '--full-auto', '--json'],
243
+ output: 'jsonl'
244
+ },
245
+ claude: {
246
+ bin: 'claude',
247
+ args: ['-p', '--output-format', 'json', '--dangerously-skip-permissions'],
248
+ output: 'json'
249
+ }
250
+ },
251
+ phases: {
252
+ plan: 'claude',
253
+ implement: 'codex',
254
+ review: 'claude'
255
+ },
256
+ resilience: {
257
+ auto_resume: false,
258
+ max_auto_resumes: 1,
259
+ auto_resume_delays_ms: [30000, 120000, 300000],
260
+ max_worker_call_minutes: 45,
261
+ max_review_rounds: 2
262
+ }
263
+ };
264
+ }
265
+ /**
266
+ * Create example task files
267
+ */
268
+ function createExampleTasks(runrDir) {
269
+ const tasksDir = path.join(runrDir, 'tasks');
270
+ fs.mkdirSync(tasksDir, { recursive: true });
271
+ const exampleBugfix = `# Fix Bug: [Description]
272
+
273
+ ## Goal
274
+ Fix [specific bug] in [component/module]
275
+
276
+ ## Requirements
277
+ - Identify root cause
278
+ - Implement fix
279
+ - Add test to prevent regression
280
+
281
+ ## Success Criteria
282
+ - Bug is fixed (verified manually or with specific test)
283
+ - All existing tests still pass
284
+ - New test added covering the bug scenario
285
+ `;
286
+ const exampleFeature = `# Add Feature: [Description]
287
+
288
+ ## Goal
289
+ Implement [feature] that allows users to [action]
290
+
291
+ ## Requirements
292
+ - [Requirement 1]
293
+ - [Requirement 2]
294
+ - [Requirement 3]
295
+
296
+ ## Success Criteria
297
+ - Feature works as described
298
+ - Tests added covering main use cases
299
+ - All verification checks pass (lint, typecheck, build, tests)
300
+ `;
301
+ const exampleDocs = `# Update Documentation
302
+
303
+ ## Goal
304
+ Update documentation for [topic/module]
305
+
306
+ ## Requirements
307
+ - Document new features/changes
308
+ - Update code examples if needed
309
+ - Fix any outdated information
310
+
311
+ ## Success Criteria
312
+ - Documentation is accurate and clear
313
+ - Examples run without errors
314
+ - All verification checks pass
315
+ `;
316
+ fs.writeFileSync(path.join(tasksDir, 'example-bugfix.md'), exampleBugfix);
317
+ fs.writeFileSync(path.join(tasksDir, 'example-feature.md'), exampleFeature);
318
+ fs.writeFileSync(path.join(tasksDir, 'example-docs.md'), exampleDocs);
319
+ }
320
+ /**
321
+ * Initialize Runr configuration for a repository
322
+ */
323
+ export async function initCommand(options) {
324
+ const repoPath = path.resolve(options.repo);
325
+ const runrDir = path.join(repoPath, '.runr');
326
+ const configPath = path.join(runrDir, 'runr.config.json');
327
+ // Handle --interactive flag
328
+ if (options.interactive) {
329
+ console.log('🚧 Interactive setup is planned for a future release');
330
+ console.log('');
331
+ console.log('For now, use `runr init` without --interactive to generate config automatically,');
332
+ console.log('then edit .runr/runr.config.json to customize verification commands.');
333
+ process.exit(0);
334
+ }
335
+ // Check if config already exists
336
+ if (fs.existsSync(configPath) && !options.force) {
337
+ console.error('Error: .runr/runr.config.json already exists');
338
+ console.error('Use --force to overwrite');
339
+ process.exit(1);
340
+ }
341
+ // Detect verification commands - try Python first, then package.json, then default
342
+ const detection = detectPythonVerification(repoPath) ||
343
+ detectFromPackageJson(repoPath) ||
344
+ generateDefaultConfig(repoPath);
345
+ // Build config
346
+ const config = buildConfig(repoPath, detection);
347
+ // If --print mode, just output and exit
348
+ if (options.print) {
349
+ console.log(JSON.stringify(config, null, 2));
350
+ return;
351
+ }
352
+ // Create .runr directory
353
+ fs.mkdirSync(runrDir, { recursive: true });
354
+ // Write config
355
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
356
+ // Create example tasks
357
+ createExampleTasks(runrDir);
358
+ // Report results
359
+ console.log('✅ Runr initialized successfully!\n');
360
+ console.log(`Config written to: ${configPath}`);
361
+ console.log(`Example tasks created in: ${path.join(runrDir, 'tasks')}/\n`);
362
+ if (detection.source === 'package.json') {
363
+ console.log('Detected from package.json:');
364
+ if (detection.verification.tier0.length > 0) {
365
+ console.log(` tier0 (fast): ${detection.verification.tier0.join(', ')}`);
366
+ }
367
+ if (detection.verification.tier1.length > 0) {
368
+ console.log(` tier1 (build): ${detection.verification.tier1.join(', ')}`);
369
+ }
370
+ if (detection.verification.tier2.length > 0) {
371
+ console.log(` tier2 (tests): ${detection.verification.tier2.join(', ')}`);
372
+ }
373
+ if (detection.presets.length > 0) {
374
+ console.log(` presets: ${detection.presets.join(', ')}`);
375
+ }
376
+ console.log('');
377
+ }
378
+ else if (detection.source === 'python') {
379
+ console.log('Detected Python project:');
380
+ if (detection.verification.tier0.length > 0) {
381
+ console.log(` tier0 (fast): ${detection.verification.tier0.join(', ')}`);
382
+ }
383
+ if (detection.verification.tier1.length > 0) {
384
+ console.log(` tier1 (build): ${detection.verification.tier1.join(', ')}`);
385
+ }
386
+ if (detection.verification.tier2.length > 0) {
387
+ console.log(` tier2 (tests): ${detection.verification.tier2.join(', ')}`);
388
+ }
389
+ if (detection.presets.length > 0) {
390
+ console.log(` presets: ${detection.presets.join(', ')}`);
391
+ }
392
+ console.log('');
393
+ }
394
+ else {
395
+ // No verification detected - provide detailed guidance
396
+ console.log('⚠️ No verification commands detected\n');
397
+ // Explain why based on the reason
398
+ const reason = detection.reason;
399
+ if (reason === 'no-package-json') {
400
+ console.log('No package.json found in this repository.');
401
+ console.log('For JavaScript/TypeScript projects, add a package.json with npm scripts.');
402
+ }
403
+ else if (reason === 'empty-scripts') {
404
+ console.log('Found package.json but it has no scripts defined.');
405
+ console.log('Add verification scripts like "test", "build", "lint", or "typecheck".');
406
+ }
407
+ else if (reason === 'no-matching-scripts') {
408
+ console.log('Found package.json with scripts, but none match common verification patterns.');
409
+ console.log('Expected scripts: test, build, lint, typecheck, tsc, eslint, jest, vitest.');
410
+ }
411
+ else if (reason === 'invalid-package-json') {
412
+ console.log('Found package.json but could not parse it (invalid JSON).');
413
+ }
414
+ console.log('');
415
+ console.log('📝 Next steps:');
416
+ console.log('');
417
+ console.log('Option 1: Manual configuration');
418
+ console.log(` • Edit: ${configPath}`);
419
+ console.log(' • Add verification commands to tier0/tier1/tier2 arrays');
420
+ console.log(' • Example tier0: ["npm run lint", "npm run typecheck"]');
421
+ console.log(' • Example tier1: ["npm run build"]');
422
+ console.log(' • Example tier2: ["npm run test"]');
423
+ console.log('');
424
+ console.log('Option 2: Interactive setup');
425
+ console.log(' • Run: runr init --interactive --force');
426
+ console.log(' • Follow prompts to configure verification');
427
+ console.log('');
428
+ }
429
+ if (detection.source !== 'none') {
430
+ console.log('Next steps:');
431
+ console.log(' 1. Review/edit .runr/runr.config.json');
432
+ console.log(' 2. Create a task file in .runr/tasks/');
433
+ console.log(' 3. Run: runr run --task .runr/tasks/your-task.md --worktree');
434
+ }
435
+ else {
436
+ console.log('After configuring verification:');
437
+ console.log(' 1. Create a task file in .runr/tasks/');
438
+ console.log(' 2. Run: runr run --task .runr/tasks/your-task.md --worktree');
439
+ }
440
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Journal commands: journal, note, open
3
+ *
4
+ * Generate case files for agent runs with notes and markdown output.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { execSync } from 'node:child_process';
9
+ import { buildJournal } from '../journal/builder.js';
10
+ import { renderJournal } from '../journal/renderer.js';
11
+ import { getRunrPaths } from '../store/runs-root.js';
12
+ /**
13
+ * Journal command: Generate and optionally display journal.md
14
+ *
15
+ * Usage:
16
+ * runr journal [run_id] [--repo <path>] [--output <file>] [--force]
17
+ */
18
+ export async function journalCommand(options) {
19
+ const repo = options.repo || process.cwd();
20
+ const runId = options.runId || findLatestRunId(repo);
21
+ if (!runId) {
22
+ console.error('ERROR: No runs found. Specify --run-id or create a run first.');
23
+ process.exit(1);
24
+ }
25
+ const runDir = path.join(getRunrPaths(repo).runs_dir, runId);
26
+ if (!fs.existsSync(runDir)) {
27
+ console.error(`ERROR: Run directory not found: ${runDir}`);
28
+ process.exit(1);
29
+ }
30
+ try {
31
+ // Build journal.json
32
+ const journal = await buildJournal(runId, repo);
33
+ // Render markdown
34
+ const markdown = renderJournal(journal);
35
+ // Determine output path
36
+ const outputPath = options.output || path.join(runDir, 'journal.md');
37
+ // Check if file exists and not forcing
38
+ if (fs.existsSync(outputPath) && !options.force) {
39
+ // Check if journal.json is newer than journal.md
40
+ const journalMtime = getMtime(path.join(runDir, 'journal.json'));
41
+ const markdownMtime = getMtime(outputPath);
42
+ if (journalMtime && markdownMtime && journalMtime <= markdownMtime) {
43
+ console.log(`✓ Journal is up to date: ${outputPath}`);
44
+ console.log(`\n${markdown}`);
45
+ return;
46
+ }
47
+ }
48
+ // Write markdown file
49
+ fs.writeFileSync(outputPath, markdown, 'utf-8');
50
+ console.log(`✓ Journal generated: ${outputPath}`);
51
+ console.log(`\n${markdown}`);
52
+ }
53
+ catch (err) {
54
+ console.error('ERROR:', err.message);
55
+ process.exit(1);
56
+ }
57
+ }
58
+ /**
59
+ * Note command: Append timestamped note to notes.jsonl
60
+ *
61
+ * Usage:
62
+ * runr note <message> [--run-id <id>] [--repo <path>]
63
+ */
64
+ export async function noteCommand(message, options) {
65
+ const repo = options.repo || process.cwd();
66
+ const runId = options.runId || findLatestRunId(repo);
67
+ if (!runId) {
68
+ console.error('ERROR: No runs found. Specify --run-id or create a run first.');
69
+ process.exit(1);
70
+ }
71
+ const runDir = path.join(getRunrPaths(repo).runs_dir, runId);
72
+ if (!fs.existsSync(runDir)) {
73
+ console.error(`ERROR: Run directory not found: ${runDir}`);
74
+ process.exit(1);
75
+ }
76
+ const notesPath = path.join(runDir, 'notes.jsonl');
77
+ // Append note
78
+ const note = {
79
+ timestamp: new Date().toISOString(),
80
+ message
81
+ };
82
+ try {
83
+ fs.appendFileSync(notesPath, JSON.stringify(note) + '\n', 'utf-8');
84
+ console.log(`✓ Note added to run ${runId}`);
85
+ console.log(` "${message}"`);
86
+ }
87
+ catch (err) {
88
+ console.error('ERROR:', err.message);
89
+ process.exit(1);
90
+ }
91
+ }
92
+ /**
93
+ * Open command: Open journal.md in editor
94
+ *
95
+ * Usage:
96
+ * runr open [run_id] [--repo <path>]
97
+ */
98
+ export async function openCommand(options) {
99
+ const repo = options.repo || process.cwd();
100
+ const runId = options.runId || findLatestRunId(repo);
101
+ if (!runId) {
102
+ console.error('ERROR: No runs found. Specify --run-id or create a run first.');
103
+ process.exit(1);
104
+ }
105
+ const runDir = path.join(getRunrPaths(repo).runs_dir, runId);
106
+ const journalPath = path.join(runDir, 'journal.md');
107
+ if (!fs.existsSync(journalPath)) {
108
+ console.log(`Journal not found. Generating...`);
109
+ try {
110
+ const journal = await buildJournal(runId, repo);
111
+ const markdown = renderJournal(journal);
112
+ fs.writeFileSync(journalPath, markdown, 'utf-8');
113
+ console.log(`✓ Journal generated: ${journalPath}`);
114
+ }
115
+ catch (err) {
116
+ console.error('ERROR:', err.message);
117
+ process.exit(1);
118
+ }
119
+ }
120
+ // Open in editor (with non-interactive safety)
121
+ const editor = process.env.EDITOR;
122
+ // Check if running in non-interactive environment (CI, no TTY)
123
+ const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
124
+ if (!isInteractive || !editor) {
125
+ // Non-interactive or no editor set: print path instead of hanging
126
+ console.log(`Journal: ${journalPath}`);
127
+ if (!editor) {
128
+ console.log('Tip: Set $EDITOR to open journals automatically');
129
+ }
130
+ return;
131
+ }
132
+ try {
133
+ execSync(`${editor} "${journalPath}"`, { stdio: 'inherit' });
134
+ }
135
+ catch (err) {
136
+ console.error(`ERROR: Failed to open editor: ${err.message}`);
137
+ process.exit(1);
138
+ }
139
+ }
140
+ /**
141
+ * Find the latest run ID in the runs directory
142
+ */
143
+ function findLatestRunId(repo) {
144
+ const runsRoot = getRunrPaths(repo).runs_dir;
145
+ if (!fs.existsSync(runsRoot)) {
146
+ return null;
147
+ }
148
+ const entries = fs.readdirSync(runsRoot, { withFileTypes: true });
149
+ const runDirs = entries
150
+ .filter((e) => e.isDirectory())
151
+ .map((e) => e.name)
152
+ .filter((name) => /^\d{14}$/.test(name)) // Format: YYYYMMDDHHMMSS
153
+ .sort()
154
+ .reverse();
155
+ return runDirs[0] || null;
156
+ }
157
+ /**
158
+ * Get file modification time (returns null if file doesn't exist)
159
+ */
160
+ function getMtime(filePath) {
161
+ try {
162
+ return fs.statSync(filePath).mtimeMs;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
@@ -0,0 +1,25 @@
1
+ import { getRunsRoot } from '../store/runs-root.js';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * Print the suggested next command from stop.json handoff
6
+ */
7
+ export async function nextCommand(runId, options = {}) {
8
+ const repoPath = options.repo || process.cwd();
9
+ const runsRoot = getRunsRoot(repoPath);
10
+ // Read stop.json directly (no need to resolve - CLI already handles "latest")
11
+ const stopJsonPath = path.join(runsRoot, runId, 'handoffs', 'stop.json');
12
+ if (!fs.existsSync(stopJsonPath)) {
13
+ console.error(`No stop handoff found for run ${runId}`);
14
+ console.error(`Expected: ${stopJsonPath}`);
15
+ process.exit(1);
16
+ }
17
+ const stopData = JSON.parse(fs.readFileSync(stopJsonPath, 'utf-8'));
18
+ if (!stopData.next_actions || stopData.next_actions.length === 0) {
19
+ console.error(`No next actions available for run ${runId}`);
20
+ process.exit(1);
21
+ }
22
+ // Print the first suggested command
23
+ const nextAction = stopData.next_actions[0];
24
+ console.log(nextAction.command);
25
+ }