@weldr/runr 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -1,57 +1,122 @@
1
1
  # Runr
2
2
 
3
- Phase-gated orchestration for agent tasks.
3
+ **Stop losing 30 minutes when the agent derails.**
4
4
 
5
- > **Status**: v0.3.0 — Renamed from `agent-runner`. Early, opinionated, evolving.
5
+ ![Failure Recovery](demo/failure-checkpoint.gif)
6
+
7
+ *When verification fails after 3 checkpoints, progress isn't lost — Runr saves verified work as git commits.*
8
+
9
+ ## Quickstart
10
+
11
+ ```bash
12
+ npm install -g @weldr/runr
13
+ cd your-repo
14
+ runr init
15
+ runr run --task .runr/tasks/your-task.md --worktree
16
+ ```
6
17
 
7
- ## The Problem
18
+ **If it stops:** Run the suggested command in `.runr/runs/<run_id>/handoffs/stop.json`
8
19
 
9
- AI agents can write code. They can also:
10
- - Claim success without verification
11
- - Modify files they shouldn't touch
12
- - Get stuck in infinite loops
13
- - Fail in ways that are impossible to debug
20
+ ![Next Action](demo/next-action.gif)
14
21
 
15
- **Runr doesn't make agents smarter. It makes them accountable.**
22
+ *Runr writes a stop handoff so agents know exactly what to do next — no guessing, no hallucinating.*
16
23
 
17
- ## What This Does
24
+ ## How It Works
18
25
 
19
- Runr orchestrates AI workers (Claude, Codex) through a phase-based workflow with hard gates:
26
+ Runr orchestrates AI workers through phase gates with checkpoints:
20
27
 
21
28
  ```
22
29
  PLAN → IMPLEMENT → VERIFY → REVIEW → CHECKPOINT → done
23
- ↑___________| (retry if needed)
30
+ ↑___________| (retry if verification fails)
24
31
  ```
25
32
 
26
- Every phase has criteria. You don't advance without meeting them.
33
+ - **Phase gates** Agent can't skip verification or claim false success
34
+ - **Checkpoints** — Verified milestones saved as git commits
35
+ - **Stop handoffs** — Structured diagnostics with next actions
36
+ - **Scope guards** — Files outside scope are protected
27
37
 
28
- ## Why Phase Gates?
38
+ > **Status**: v0.3.0 — Renamed from `agent-runner`. Early, opinionated, evolving.
39
+
40
+ ## Meta-Agent Quickstart (Recommended)
29
41
 
30
- Most agent tools optimize for speed. Runr optimizes for **trust**.
42
+ **The easiest way to use Runr:** Let your coding agent drive it.
31
43
 
32
- When a run fails (and it will), you get:
33
- - **Structured diagnostics** — exactly why it stopped
34
- - **Checkpoints** — resume from where it failed
35
- - **Scope guards** — files it couldn't touch, it didn't touch
36
- - **Evidence** — "done" means "proven done"
44
+ Runr works as a **reliable execution backend**. Instead of learning CLI commands, your agent (Claude Code, Codex, etc.) operates Runr for you — handling runs, interpreting failures, and resuming from checkpoints.
37
45
 
38
- ## Quick Start
46
+ ### Setup (One-Time)
39
47
 
40
48
  ```bash
41
- # Install
42
- git clone https://github.com/vonwao/runr.git
43
- cd runr && npm install && npm run build && npm link
49
+ # 1. Install Runr
50
+ npm install -g @weldr/runr
44
51
 
45
- # Verify
46
- runr version
52
+ # 2. Verify environment
47
53
  runr doctor
48
54
 
49
- # Run a task
55
+ # 3. Create minimal config
56
+ mkdir -p .runr/tasks
57
+ cat > .runr/runr.config.json << 'EOF'
58
+ {
59
+ "agent": { "name": "my-project", "version": "1" },
60
+ "scope": {
61
+ "presets": ["typescript", "vitest"]
62
+ },
63
+ "verification": {
64
+ "tier0": ["npm run typecheck"],
65
+ "tier1": ["npm test"]
66
+ }
67
+ }
68
+ EOF
69
+ ```
70
+
71
+ ### Usage
72
+
73
+ Just tell your coding agent:
74
+
75
+ > "Use Runr to add user authentication with OAuth2. Create checkpoints after each milestone."
76
+
77
+ The agent will:
78
+ 1. Create a task file (`.runr/tasks/add-auth.md`)
79
+ 2. Run `runr run --task ... --worktree`
80
+ 3. Monitor progress with `runr status`
81
+ 4. Handle failures, resume from checkpoints
82
+ 5. Report results with commit links
83
+
84
+ **See [RUNR_OPERATOR.md](./RUNR_OPERATOR.md)** for the complete agent integration guide.
85
+
86
+ ### Why This Works
87
+
88
+ Most devs already have a coding agent open. Telling them:
89
+ - "Drop this in your agent, and it'll drive Runr for you"
90
+
91
+ …has near-zero friction compared to:
92
+ - "Learn these CLI commands, create config files, understand phase gates"
93
+
94
+ The agent becomes your operator. Runr stays the reliable execution layer.
95
+
96
+ ---
97
+
98
+ ## Quick Start (Direct CLI)
99
+
100
+ ```bash
101
+ # Install
102
+ npm install -g @weldr/runr
103
+
104
+ # Initialize in your project
50
105
  cd /your/project
51
- runr run --task .runr/tasks/my-task.md --worktree
106
+ runr init
107
+
108
+ # Run a task
109
+ runr run --task .runr/tasks/example-feature.md --worktree
110
+
111
+ # If it fails, resume from last checkpoint
112
+ runr resume <run_id>
113
+
114
+ # Get machine-readable diagnostics
115
+ runr summarize <run_id>
116
+ # Output: .runr/runs/<run_id>/handoffs/stop.json
52
117
  ```
53
118
 
54
- > Not on npm yet. Coming soon as `@weldr/runr`.
119
+ > Prefer source install? See [Development](#development).
55
120
 
56
121
  ## Configuration
57
122
 
@@ -91,15 +156,17 @@ Available: `nextjs`, `react`, `drizzle`, `prisma`, `vitest`, `jest`, `playwright
91
156
 
92
157
  | Command | What it does |
93
158
  |---------|--------------|
159
+ | `runr init` | Initialize config (auto-detect verify commands) |
94
160
  | `runr run --task <file>` | Start a task |
95
161
  | `runr resume <id>` | Continue from checkpoint |
162
+ | `runr watch <id> --auto-resume` | Watch run + auto-resume on failure |
96
163
  | `runr status [id]` | Show run state |
97
164
  | `runr follow [id]` | Tail run progress |
98
- | `runr report <id>` | Generate run report |
165
+ | `runr report <id>` | Generate run report (includes next_action) |
99
166
  | `runr gc` | Clean up old runs |
100
167
  | `runr doctor` | Check environment |
101
168
 
102
- ### The Fun Commands
169
+ ### Aliases
103
170
 
104
171
  Same functionality, different vibe:
105
172
 
@@ -146,26 +213,23 @@ Every stop produces `stop.json` + `stop.md` with diagnostics.
146
213
 
147
214
  ## Philosophy
148
215
 
149
- **This is not magic.** Runs fail. The goal is *understandable, resumable* failure.
216
+ This isn't magic. Runs fail. The goal is understandable, resumable failure.
150
217
 
151
- **This is not a chatbot.** Task in, code out. No conversation.
218
+ This isn't a chatbot. Task in, code out.
152
219
 
153
- **This is not a code generator.** It orchestrates generators. Different job.
220
+ This isn't a code generator. It orchestrates generators.
154
221
 
155
- **Agents lie. Logs don't.** If it can't prove it, it didn't do it.
222
+ Agents lie. Logs don't. If it can't prove it, it didn't do it.
156
223
 
157
224
  ## Migrating from agent-runner
158
225
 
159
- If you're upgrading from `agent-runner`:
160
-
161
226
  | Old | New |
162
227
  |-----|-----|
163
228
  | `agent` CLI | `runr` CLI |
164
229
  | `.agent/` directory | `.runr/` directory |
165
230
  | `agent.config.json` | `runr.config.json` |
166
231
  | `.agent-worktrees/` | `.runr-worktrees/` |
167
-
168
- Both old and new locations work during the transition period. You'll see deprecation warnings for old locations.
232
+ Old paths still work for now, with deprecation warnings.
169
233
 
170
234
  ## Development
171
235
 
@@ -179,21 +243,21 @@ npm run dev -- run --task task.md # run from source
179
243
 
180
244
  | Version | Date | Highlights |
181
245
  |---------|------|------------|
182
- | v0.3.0 | 2026-01-01 | **Renamed to Runr**, new CLI, new directory structure |
183
- | v0.2.2 | 2025-12-31 | Worktree location fix, guard diagnostics |
184
- | v0.2.1 | 2025-12-29 | Scope presets, review digest |
185
- | v0.2.0 | 2025-12-28 | Review loop detection |
186
- | v0.1.0 | 2025-12-27 | Initial stable release |
246
+ | v0.3.0 | **Renamed to Runr**, new CLI, new directory structure |
247
+ | v0.2.2 | Worktree location fix, guard diagnostics |
248
+ | v0.2.1 | Scope presets, review digest |
249
+ | v0.2.0 | Review loop detection |
250
+ | v0.1.0 | Initial stable release |
187
251
 
188
- See [CHANGELOG.md](CHANGELOG.md) for detailed release notes.
252
+ See [CHANGELOG.md](CHANGELOG.md) for details.
189
253
 
190
254
  ## Contributing
191
255
 
192
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
256
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
193
257
 
194
258
  ## License
195
259
 
196
- Apache 2.0 — See [LICENSE](LICENSE)
260
+ Apache 2.0 — See [LICENSE](LICENSE).
197
261
 
198
262
  ---
199
263
 
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { resumeCommand } from './commands/resume.js';
5
5
  import { statusCommand, statusAllCommand } from './commands/status.js';
6
6
  import { reportCommand, findLatestRunId } from './commands/report.js';
7
7
  import { summarizeCommand } from './commands/summarize.js';
8
+ import { nextCommand } from './commands/next.js';
8
9
  import { compareCommand } from './commands/compare.js';
9
10
  import { guardsOnlyCommand } from './commands/guards-only.js';
10
11
  import { doctorCommand } from './commands/doctor.js';
@@ -15,6 +16,8 @@ import { orchestrateCommand, resumeOrchestrationCommand, waitOrchestrationComman
15
16
  import { pathsCommand } from './commands/paths.js';
16
17
  import { metricsCommand } from './commands/metrics.js';
17
18
  import { versionCommand } from './commands/version.js';
19
+ import { initCommand } from './commands/init.js';
20
+ import { watchCommand } from './commands/watch.js';
18
21
  const program = new Command();
19
22
  // Check if invoked as deprecated 'agent' command
20
23
  const invokedAs = process.argv[1]?.split('/').pop() || 'runr';
@@ -24,6 +27,21 @@ if (invokedAs === 'agent') {
24
27
  program
25
28
  .name('runr')
26
29
  .description('Phase-gated orchestration for agent tasks');
30
+ program
31
+ .command('init')
32
+ .description('Initialize Runr configuration for a repository')
33
+ .option('--repo <path>', 'Path to repository (defaults to current directory)', '.')
34
+ .option('--interactive', 'Launch interactive setup wizard to configure verification commands', false)
35
+ .option('--print', 'Display generated config in terminal without writing to disk', false)
36
+ .option('--force', 'Overwrite existing .runr/runr.config.json if present', false)
37
+ .action(async (options) => {
38
+ await initCommand({
39
+ repo: options.repo,
40
+ interactive: options.interactive,
41
+ print: options.print,
42
+ force: options.force
43
+ });
44
+ });
27
45
  program
28
46
  .command('run')
29
47
  .option('--repo <path>', 'Target repo path (default: current directory)', '.')
@@ -132,6 +150,7 @@ program
132
150
  .option('--repo <path>', 'Target repo path (default: current directory)', '.')
133
151
  .option('--tail <count>', 'Tail last N events', '50')
134
152
  .option('--kpi-only', 'Show compact KPI summary only')
153
+ .option('--json', 'Output KPI as JSON (includes next_action and suggested_command)')
135
154
  .action(async (runId, options) => {
136
155
  let resolvedRunId = runId;
137
156
  if (runId === 'latest') {
@@ -146,7 +165,8 @@ program
146
165
  runId: resolvedRunId,
147
166
  repo: options.repo,
148
167
  tail: Number.parseInt(options.tail, 10),
149
- kpiOnly: options.kpiOnly
168
+ kpiOnly: options.kpiOnly,
169
+ json: options.json
150
170
  });
151
171
  });
152
172
  program
@@ -166,6 +186,23 @@ program
166
186
  }
167
187
  await summarizeCommand({ runId: resolvedRunId, repo: options.repo });
168
188
  });
189
+ program
190
+ .command('next')
191
+ .description('Print suggested next command from stop handoff')
192
+ .argument('<runId>', 'Run ID (or "latest")')
193
+ .option('--repo <path>', 'Target repo path (default: current directory)', '.')
194
+ .action(async (runId, options) => {
195
+ let resolvedRunId = runId;
196
+ if (runId === 'latest') {
197
+ const latest = findLatestRunId(options.repo);
198
+ if (!latest) {
199
+ console.error('No runs found');
200
+ process.exit(1);
201
+ }
202
+ resolvedRunId = latest;
203
+ }
204
+ await nextCommand(resolvedRunId, { repo: options.repo });
205
+ });
169
206
  program
170
207
  .command('compare')
171
208
  .description('Compare KPIs between two runs')
@@ -258,6 +295,25 @@ program
258
295
  olderThan: Number.parseInt(options.olderThan, 10)
259
296
  });
260
297
  });
298
+ program
299
+ .command('watch')
300
+ .description('Watch run progress and optionally auto-resume on failure')
301
+ .argument('<runId>', 'Run ID to watch')
302
+ .option('--repo <path>', 'Target repo path (default: current directory)', '.')
303
+ .option('--auto-resume', 'Automatically resume on transient failures', false)
304
+ .option('--max-attempts <N>', 'Maximum auto-resume attempts (default: 3)', '3')
305
+ .option('--interval <seconds>', 'Poll interval in seconds (default: 5)', '5')
306
+ .option('--json', 'Output JSON events', false)
307
+ .action(async (runId, options) => {
308
+ await watchCommand({
309
+ runId,
310
+ repo: options.repo,
311
+ autoResume: options.autoResume,
312
+ maxAttempts: Number.parseInt(options.maxAttempts, 10),
313
+ interval: Number.parseInt(options.interval, 10) * 1000,
314
+ json: options.json
315
+ });
316
+ });
261
317
  program
262
318
  .command('wait')
263
319
  .description('Block until run reaches terminal state (for meta-agent coordination)')
@@ -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,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
+ }
@@ -19,6 +19,9 @@ export async function reportCommand(options) {
19
19
  const timelinePath = path.join(runDir, 'timeline.jsonl');
20
20
  const defaultKpi = {
21
21
  version: 1,
22
+ run_id: options.runId,
23
+ phase: state.phase || null,
24
+ checkpoint_sha: state.checkpoint_commit_sha || null,
22
25
  total_duration_ms: null,
23
26
  unattributed_ms: null,
24
27
  started_at: null,
@@ -26,7 +29,7 @@ export async function reportCommand(options) {
26
29
  phases: {},
27
30
  workers: { claude: 'unknown', codex: 'unknown' },
28
31
  verify: { attempts: 0, retries: 0, total_duration_ms: 0 },
29
- milestones: { completed: 0 },
32
+ milestones: { completed: 0, total: state.milestones?.length || 0 },
30
33
  reliability: {
31
34
  infra_retries: 0,
32
35
  fallback_used: false,
@@ -35,11 +38,18 @@ export async function reportCommand(options) {
35
38
  late_results_ignored: 0
36
39
  },
37
40
  outcome: 'unknown',
38
- stop_reason: null
41
+ stop_reason: null,
42
+ next_action: 'inspect_logs',
43
+ suggested_command: null
39
44
  };
40
45
  const scan = fs.existsSync(timelinePath)
41
46
  ? await scanTimeline(timelinePath, options.tail)
42
47
  : { tailEvents: [], kpi: defaultKpi };
48
+ // Merge run-specific fields into KPI (these aren't in timeline events)
49
+ scan.kpi.run_id = options.runId;
50
+ scan.kpi.phase = state.phase || null;
51
+ scan.kpi.checkpoint_sha = state.checkpoint_commit_sha || null;
52
+ scan.kpi.milestones.total = state.milestones?.length || 0;
43
53
  const flags = readFlags(scan.runStarted);
44
54
  const contextPackArtifact = readContextPackArtifact(runDir);
45
55
  const header = [
@@ -57,6 +67,11 @@ export async function reportCommand(options) {
57
67
  `allow_deps: ${flags.allow_deps ?? 'unknown'}`
58
68
  ].join('\n');
59
69
  const kpiBlock = formatKpiBlock(scan.kpi, contextPackArtifact);
70
+ if (options.json) {
71
+ // JSON output: full KPI object with next_action and suggested_command
72
+ console.log(JSON.stringify(scan.kpi, null, 2));
73
+ return;
74
+ }
60
75
  if (options.kpiOnly) {
61
76
  // Compact output: just run_id and KPIs
62
77
  console.log(`${options.runId}: ${scan.kpi.outcome} ${formatDuration(scan.kpi.total_duration_ms)} milestones=${scan.kpi.milestones.completed}`);
@@ -322,8 +337,41 @@ export function computeKpiFromEvents(events) {
322
337
  const attributedMs = Object.values(phases).reduce((sum, p) => sum + p.duration_ms, 0);
323
338
  unattributedMs = totalDurationMs - attributedMs;
324
339
  }
340
+ // Compute next_action and suggested_command
341
+ let nextAction = 'inspect_logs';
342
+ let suggestedCommand = null;
343
+ if (outcome === 'complete') {
344
+ nextAction = 'none';
345
+ }
346
+ else if (outcome === 'stopped' && stopReason) {
347
+ const resumableReasons = ['verification_failed_max_retries', 'stalled_timeout', 'max_ticks_reached', 'time_budget_exceeded', 'implement_blocked'];
348
+ const scopeViolationReasons = ['guard_violation', 'plan_scope_violation', 'ownership_violation'];
349
+ const configIssueReasons = ['plan_parse_failed', 'implement_parse_failed', 'review_parse_failed'];
350
+ if (resumableReasons.includes(stopReason)) {
351
+ nextAction = 'resume';
352
+ suggestedCommand = `runr resume <run_id>`;
353
+ }
354
+ else if (scopeViolationReasons.includes(stopReason)) {
355
+ nextAction = 'resolve_scope_violation';
356
+ suggestedCommand = `# Review .runr/runr.config.json scope settings`;
357
+ }
358
+ else if (stopReason === 'parallel_file_collision') {
359
+ nextAction = 'resolve_branch_mismatch';
360
+ suggestedCommand = `# Wait for conflicting run to finish, then resume`;
361
+ }
362
+ else if (configIssueReasons.includes(stopReason)) {
363
+ nextAction = 'fix_config';
364
+ suggestedCommand = `runr init --interactive`;
365
+ }
366
+ }
367
+ else if (outcome === 'running') {
368
+ suggestedCommand = `runr status <run_id>`;
369
+ }
325
370
  return {
326
371
  version: 1,
372
+ run_id: '', // Will be filled by reportCommand
373
+ phase: null, // Will be filled by reportCommand
374
+ checkpoint_sha: null, // Will be filled by reportCommand
327
375
  total_duration_ms: totalDurationMs,
328
376
  unattributed_ms: unattributedMs,
329
377
  started_at: startedAt,
@@ -339,7 +387,8 @@ export function computeKpiFromEvents(events) {
339
387
  total_duration_ms: verifyDurationMs
340
388
  },
341
389
  milestones: {
342
- completed: milestonesCompleted
390
+ completed: milestonesCompleted,
391
+ total: 0 // Will be filled by reportCommand
343
392
  },
344
393
  reliability: {
345
394
  infra_retries: infraRetries,
@@ -349,7 +398,9 @@ export function computeKpiFromEvents(events) {
349
398
  late_results_ignored: lateResultsIgnored
350
399
  },
351
400
  outcome,
352
- stop_reason: stopReason
401
+ stop_reason: stopReason,
402
+ next_action: nextAction,
403
+ suggested_command: suggestedCommand
353
404
  };
354
405
  }
355
406
  async function scanTimeline(timelinePath, tailCount) {
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getRunsRoot } from '../store/runs-root.js';
4
+ import { resumeCommand } from './resume.js';
5
+ /**
6
+ * Check if a failure is resumable (transient or recoverable)
7
+ */
8
+ function isResumable(state) {
9
+ if (state.phase !== 'STOPPED') {
10
+ return false;
11
+ }
12
+ const resumableReasons = [
13
+ 'verification_failed_max_retries',
14
+ 'stalled_timeout',
15
+ 'max_ticks_reached',
16
+ 'time_budget_exceeded',
17
+ 'implement_blocked'
18
+ ];
19
+ const nonResumableReasons = [
20
+ 'guard_violation',
21
+ 'plan_scope_violation',
22
+ 'ownership_violation',
23
+ 'review_loop_detected',
24
+ 'parallel_file_collision'
25
+ ];
26
+ if (!state.stop_reason) {
27
+ return false;
28
+ }
29
+ if (resumableReasons.includes(state.stop_reason)) {
30
+ return true;
31
+ }
32
+ if (nonResumableReasons.includes(state.stop_reason)) {
33
+ return false;
34
+ }
35
+ // Unknown stop reason: default to non-resumable (safe)
36
+ return false;
37
+ }
38
+ /**
39
+ * Read current run state
40
+ */
41
+ function readState(repo, runId) {
42
+ const statePath = path.join(getRunsRoot(repo), runId, 'state.json');
43
+ if (!fs.existsSync(statePath)) {
44
+ return null;
45
+ }
46
+ try {
47
+ const raw = fs.readFileSync(statePath, 'utf-8');
48
+ return JSON.parse(raw);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Emit watch event
56
+ */
57
+ function emitEvent(event, json) {
58
+ if (json) {
59
+ console.log(JSON.stringify(event));
60
+ }
61
+ else {
62
+ switch (event.event) {
63
+ case 'watching':
64
+ console.log(`[${event.timestamp}] Watching run ${event.run_id} (phase: ${event.phase})`);
65
+ break;
66
+ case 'failed':
67
+ console.log(`[${event.timestamp}] Run ${event.run_id} failed: ${event.stop_reason}`);
68
+ break;
69
+ case 'resumed':
70
+ console.log(`[${event.timestamp}] Auto-resuming (attempt ${event.attempt}/${event.max_attempts}) from checkpoint ${event.checkpoint || 'unknown'}`);
71
+ break;
72
+ case 'succeeded':
73
+ console.log(`[${event.timestamp}] Run ${event.run_id} completed successfully!`);
74
+ break;
75
+ case 'max_attempts':
76
+ console.log(`[${event.timestamp}] Max auto-resume attempts (${event.max_attempts}) reached. Stopped.`);
77
+ break;
78
+ case 'non_resumable':
79
+ console.log(`[${event.timestamp}] Run ${event.run_id} stopped with non-resumable reason: ${event.stop_reason}`);
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * Sleep for N milliseconds
86
+ */
87
+ function sleep(ms) {
88
+ return new Promise(resolve => setTimeout(resolve, ms));
89
+ }
90
+ /**
91
+ * Watch a run and optionally auto-resume on failure
92
+ */
93
+ export async function watchCommand(options) {
94
+ const interval = options.interval || 5000; // 5 seconds default
95
+ const maxAttempts = options.maxAttempts ?? (options.autoResume ? 3 : 0);
96
+ let attemptCount = 0;
97
+ while (true) {
98
+ const state = readState(options.repo, options.runId);
99
+ if (!state) {
100
+ console.error(`Error: Run ${options.runId} not found`);
101
+ process.exit(1);
102
+ }
103
+ const timestamp = new Date().toISOString();
104
+ // Check if terminal
105
+ if (state.phase === 'STOPPED') {
106
+ if (state.stop_reason === 'complete') {
107
+ // Success
108
+ emitEvent({
109
+ timestamp,
110
+ run_id: options.runId,
111
+ event: 'succeeded'
112
+ }, options.json || false);
113
+ process.exit(0);
114
+ }
115
+ // Failed
116
+ emitEvent({
117
+ timestamp,
118
+ run_id: options.runId,
119
+ event: 'failed',
120
+ stop_reason: state.stop_reason,
121
+ checkpoint: state.checkpoint_commit_sha
122
+ }, options.json || false);
123
+ // Check if auto-resume
124
+ if (options.autoResume && isResumable(state)) {
125
+ if (attemptCount >= maxAttempts) {
126
+ emitEvent({
127
+ timestamp,
128
+ run_id: options.runId,
129
+ event: 'max_attempts',
130
+ max_attempts: maxAttempts
131
+ }, options.json || false);
132
+ process.exit(1);
133
+ }
134
+ attemptCount++;
135
+ emitEvent({
136
+ timestamp,
137
+ run_id: options.runId,
138
+ event: 'resumed',
139
+ attempt: attemptCount,
140
+ max_attempts: maxAttempts,
141
+ checkpoint: state.checkpoint_commit_sha
142
+ }, options.json || false);
143
+ // Cooldown before resume (10 seconds)
144
+ await sleep(10000);
145
+ // Resume (don't await, let it run in background)
146
+ try {
147
+ await resumeCommand({
148
+ runId: options.runId,
149
+ repo: options.repo,
150
+ time: 120,
151
+ maxTicks: 50,
152
+ allowDeps: false,
153
+ force: false,
154
+ autoResume: false
155
+ });
156
+ }
157
+ catch (err) {
158
+ console.error(`Resume failed: ${err}`);
159
+ process.exit(1);
160
+ }
161
+ // Continue watching after resume
162
+ await sleep(interval);
163
+ continue;
164
+ }
165
+ else {
166
+ // Non-resumable or auto-resume disabled
167
+ if (!isResumable(state)) {
168
+ emitEvent({
169
+ timestamp,
170
+ run_id: options.runId,
171
+ event: 'non_resumable',
172
+ stop_reason: state.stop_reason
173
+ }, options.json || false);
174
+ }
175
+ process.exit(1);
176
+ }
177
+ }
178
+ // Still running
179
+ emitEvent({
180
+ timestamp,
181
+ run_id: options.runId,
182
+ event: 'watching',
183
+ phase: state.phase
184
+ }, options.json || false);
185
+ await sleep(interval);
186
+ }
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weldr/runr",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Phase-gated orchestration for agent tasks",
5
5
  "type": "module",
6
6
  "bin": {