@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 +111 -47
- package/dist/cli.js +57 -1
- package/dist/commands/init.js +440 -0
- package/dist/commands/next.js +25 -0
- package/dist/commands/report.js +55 -4
- package/dist/commands/watch.js +187 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,57 +1,122 @@
|
|
|
1
1
|
# Runr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Stop losing 30 minutes when the agent derails.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+

|
|
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
|
-
|
|
18
|
+
**If it stops:** Run the suggested command in `.runr/runs/<run_id>/handoffs/stop.json`
|
|
8
19
|
|
|
9
|
-
|
|
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
|
+

|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
*Runr writes a stop handoff so agents know exactly what to do next — no guessing, no hallucinating.*
|
|
16
23
|
|
|
17
|
-
##
|
|
24
|
+
## How It Works
|
|
18
25
|
|
|
19
|
-
Runr orchestrates AI workers
|
|
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
|
|
30
|
+
↑___________| (retry if verification fails)
|
|
24
31
|
```
|
|
25
32
|
|
|
26
|
-
|
|
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
|
-
|
|
38
|
+
> **Status**: v0.3.0 — Renamed from `agent-runner`. Early, opinionated, evolving.
|
|
39
|
+
|
|
40
|
+
## Meta-Agent Quickstart (Recommended)
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
**The easiest way to use Runr:** Let your coding agent drive it.
|
|
31
43
|
|
|
32
|
-
|
|
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
|
-
|
|
46
|
+
### Setup (One-Time)
|
|
39
47
|
|
|
40
48
|
```bash
|
|
41
|
-
# Install
|
|
42
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
>
|
|
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
|
-
###
|
|
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
|
-
|
|
216
|
+
This isn't magic. Runs fail. The goal is understandable, resumable failure.
|
|
150
217
|
|
|
151
|
-
|
|
218
|
+
This isn't a chatbot. Task in, code out.
|
|
152
219
|
|
|
153
|
-
|
|
220
|
+
This isn't a code generator. It orchestrates generators.
|
|
154
221
|
|
|
155
|
-
|
|
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 |
|
|
183
|
-
| v0.2.2 |
|
|
184
|
-
| v0.2.1 |
|
|
185
|
-
| v0.2.0 |
|
|
186
|
-
| v0.1.0 |
|
|
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
|
|
252
|
+
See [CHANGELOG.md](CHANGELOG.md) for details.
|
|
189
253
|
|
|
190
254
|
## Contributing
|
|
191
255
|
|
|
192
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
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
|
+
}
|
package/dist/commands/report.js
CHANGED
|
@@ -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
|
+
}
|