@ikunin/sprintpilot 2.1.2 → 2.1.3
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/_Sprintpilot/bin/autopilot.js +264 -39
- package/_Sprintpilot/lib/orchestrator/git-plan.js +522 -36
- package/_Sprintpilot/lib/orchestrator/land.js +11 -1
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +65 -1
- package/_Sprintpilot/lib/orchestrator/state-machine.js +159 -4
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/scripts/create-pr.js +178 -7
- package/_Sprintpilot/scripts/run-step.js +221 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +35 -1
- package/package.json +1 -1
|
@@ -9,8 +9,10 @@ const log = require('../lib/runtime/log');
|
|
|
9
9
|
|
|
10
10
|
function help() {
|
|
11
11
|
log.out(
|
|
12
|
-
"Usage: create-pr.js --platform <github|gitlab|bitbucket|gitea|git_only> --branch <name> --base <branch> --title 'title' --body 'body' [--base-url <url>]",
|
|
12
|
+
"Usage: create-pr.js --mode <create|checks> --platform <github|gitlab|bitbucket|gitea|git_only> --branch <name> --base <branch> [--title 'title' --body 'body'] [--wait-minutes N] [--require-approved-review] [--base-url <url>]",
|
|
13
13
|
);
|
|
14
|
+
log.out(' --mode create (default) — open a PR. Requires --title.');
|
|
15
|
+
log.out(' --mode checks — poll an existing PR for CI / review status.');
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
async function hasCli(name) {
|
|
@@ -18,6 +20,18 @@ async function hasCli(name) {
|
|
|
18
20
|
return r.exitCode === 0;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// Resolve `--platform auto` to a concrete provider by probing for an
|
|
24
|
+
// installed CLI in priority order. Used by both --mode create and
|
|
25
|
+
// --mode checks. Returns 'git_only' when nothing is installed so the
|
|
26
|
+
// downstream branches surface a clear SKIPPED exit.
|
|
27
|
+
async function resolveAutoPlatform() {
|
|
28
|
+
if (await hasCli('gh')) return 'github';
|
|
29
|
+
if (await hasCli('glab')) return 'gitlab';
|
|
30
|
+
if (await hasCli('bb')) return 'bitbucket';
|
|
31
|
+
if (await hasCli('tea')) return 'gitea';
|
|
32
|
+
return 'git_only';
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
// Accept only safe path components so a hostile remote URL can't inject
|
|
22
36
|
// into the REST API path. Both segments must match this pattern; the full
|
|
23
37
|
// path (repo name plus any GitLab subgroup segments) must contain only
|
|
@@ -88,7 +102,9 @@ function redactAuth(text) {
|
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
async function main() {
|
|
91
|
-
const { opts } = parseArgs(process.argv.slice(2), {
|
|
105
|
+
const { opts } = parseArgs(process.argv.slice(2), {
|
|
106
|
+
booleanFlags: ['dry-run', 'require-approved-review'],
|
|
107
|
+
});
|
|
92
108
|
if (opts.help) {
|
|
93
109
|
help();
|
|
94
110
|
process.exit(0);
|
|
@@ -101,12 +117,35 @@ async function main() {
|
|
|
101
117
|
const body = opts.body || '';
|
|
102
118
|
const baseUrl = opts['base-url'];
|
|
103
119
|
const dryRun = !!opts['dry-run'];
|
|
120
|
+
const mode = opts.mode || 'create';
|
|
121
|
+
const waitMinutes = Number.parseFloat(opts['wait-minutes'] || '30');
|
|
122
|
+
const requireApprovedReview = !!opts['require-approved-review'];
|
|
123
|
+
|
|
124
|
+
// --mode checks: poll the platform for CI / review status of an
|
|
125
|
+
// existing PR. Required by land.js when merge_strategy=land_as_you_go +
|
|
126
|
+
// land_when ∈ {ci_pass, ci_and_review}. Distinct argument surface from
|
|
127
|
+
// --mode create (no --title needed).
|
|
128
|
+
if (mode === 'checks') {
|
|
129
|
+
if (!platform || !branch) {
|
|
130
|
+
log.error('--mode checks requires --platform and --branch');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
const resolved = platform === 'auto' ? await resolveAutoPlatform() : platform;
|
|
134
|
+
await runChecksMode({ platform: resolved, branch, baseBranch, waitMinutes, requireApprovedReview, baseUrl });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
104
137
|
|
|
105
138
|
if (!platform || !branch || !title) {
|
|
106
139
|
log.error('--platform, --branch, and --title are required');
|
|
107
140
|
process.exit(1);
|
|
108
141
|
}
|
|
109
142
|
|
|
143
|
+
// Resolve `auto` to a concrete provider via CLI probing. This honors
|
|
144
|
+
// the documented default in modules/git/config.yaml#platform.provider.
|
|
145
|
+
// Without this resolution, the platform === 'github'/'gitlab'/... if-
|
|
146
|
+
// chain below falls through to "unknown platform" exit 1.
|
|
147
|
+
const resolvedPlatform = platform === 'auto' ? await resolveAutoPlatform() : platform;
|
|
148
|
+
|
|
110
149
|
const remote = await tryGitStdout(['remote', 'get-url', 'origin']);
|
|
111
150
|
if (!remote) {
|
|
112
151
|
log.out('SKIPPED');
|
|
@@ -124,19 +163,42 @@ async function main() {
|
|
|
124
163
|
return;
|
|
125
164
|
}
|
|
126
165
|
|
|
127
|
-
if (
|
|
166
|
+
if (resolvedPlatform === 'git_only') {
|
|
128
167
|
log.out('SKIPPED');
|
|
129
168
|
log.err('INFO: No platform CLI available. Push completed. Create PR manually:');
|
|
130
169
|
log.err(` Branch: ${branch} → ${baseBranch}`);
|
|
131
170
|
process.exit(2);
|
|
132
171
|
}
|
|
133
172
|
|
|
134
|
-
if (
|
|
173
|
+
if (resolvedPlatform === 'github') {
|
|
135
174
|
if (!(await hasCli('gh'))) {
|
|
136
175
|
log.err('WARN: gh CLI not found, skipping PR creation');
|
|
137
176
|
log.out('SKIPPED');
|
|
138
177
|
process.exit(2);
|
|
139
178
|
}
|
|
179
|
+
// Idempotency: if a PR already exists for this branch (granularity=
|
|
180
|
+
// epic re-pushes onto the same branch, or a manual resume), return
|
|
181
|
+
// its URL and exit 0 instead of hitting `gh pr create` which fails
|
|
182
|
+
// hard with "a pull request for branch X already exists".
|
|
183
|
+
//
|
|
184
|
+
// `gh pr list --head <branch> --json url --limit 1` is the canonical
|
|
185
|
+
// "PRs for this head" query. It exits 0 with `[]` when no PR exists
|
|
186
|
+
// (distinct from `gh pr view` which exits 1 for both "no PR" and
|
|
187
|
+
// "auth failed" — ambiguous). We only short-circuit on a non-empty
|
|
188
|
+
// array; any other exit falls through to `gh pr create` so transient
|
|
189
|
+
// errors don't suppress PR creation forever.
|
|
190
|
+
const existing = await tryRun(
|
|
191
|
+
'gh',
|
|
192
|
+
['pr', 'list', '--head', branch, '--json', 'url', '--limit', '1', '--jq', '.[0].url // ""'],
|
|
193
|
+
{ timeoutMs: 15_000 },
|
|
194
|
+
);
|
|
195
|
+
if (existing.exitCode === 0) {
|
|
196
|
+
const url = (existing.stdout || '').trim();
|
|
197
|
+
if (url) {
|
|
198
|
+
log.out(url);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
140
202
|
const r = await tryRun(
|
|
141
203
|
'gh',
|
|
142
204
|
['pr', 'create', '--base', baseBranch, '--head', branch, '--title', title, '--body', body],
|
|
@@ -144,6 +206,13 @@ async function main() {
|
|
|
144
206
|
);
|
|
145
207
|
const combined = `${r.stdout}${r.stderr}`;
|
|
146
208
|
if (r.exitCode !== 0) {
|
|
209
|
+
// Backstop: gh's "already exists" error message can race with our
|
|
210
|
+
// pre-check (push lands a PR between `view` and `create`). Detect
|
|
211
|
+
// it in stderr and treat as success.
|
|
212
|
+
if (/already exists/i.test(combined)) {
|
|
213
|
+
log.out(combined.trim());
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
147
216
|
log.error(`gh pr create failed: ${combined.trim()}`);
|
|
148
217
|
process.exit(1);
|
|
149
218
|
}
|
|
@@ -151,7 +220,7 @@ async function main() {
|
|
|
151
220
|
return;
|
|
152
221
|
}
|
|
153
222
|
|
|
154
|
-
if (
|
|
223
|
+
if (resolvedPlatform === 'gitlab') {
|
|
155
224
|
if (!(await hasCli('glab'))) {
|
|
156
225
|
log.err('WARN: glab CLI not found, skipping MR creation');
|
|
157
226
|
log.out('SKIPPED');
|
|
@@ -185,7 +254,7 @@ async function main() {
|
|
|
185
254
|
return;
|
|
186
255
|
}
|
|
187
256
|
|
|
188
|
-
if (
|
|
257
|
+
if (resolvedPlatform === 'bitbucket') {
|
|
189
258
|
if (await hasCli('bb')) {
|
|
190
259
|
const r = await tryRun(
|
|
191
260
|
'bb',
|
|
@@ -247,7 +316,7 @@ async function main() {
|
|
|
247
316
|
process.exit(2);
|
|
248
317
|
}
|
|
249
318
|
|
|
250
|
-
if (
|
|
319
|
+
if (resolvedPlatform === 'gitea') {
|
|
251
320
|
if (await hasCli('tea')) {
|
|
252
321
|
const r = await tryRun(
|
|
253
322
|
'tea',
|
|
@@ -304,10 +373,112 @@ async function main() {
|
|
|
304
373
|
process.exit(2);
|
|
305
374
|
}
|
|
306
375
|
|
|
376
|
+
log.error(`unknown platform '${resolvedPlatform}'`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --mode checks: poll an existing PR's CI status (and optionally review
|
|
381
|
+
// status) until success, failure, or timeout. Polling interval is 30s
|
|
382
|
+
// with ±5s of uniform jitter; the watchdog cap is `waitMinutes`.
|
|
383
|
+
//
|
|
384
|
+
// Effective per-cycle wall time can be up to ~60s when `gh pr checks`
|
|
385
|
+
// itself takes the full 30s of its --timeoutMs before timing out on a
|
|
386
|
+
// pending check, plus a 30s±5s sleep. Therefore the actual elapsed
|
|
387
|
+
// time before declaring "timed out" can exceed `waitMinutes` by up to
|
|
388
|
+
// ~one cycle (~60s). Set wait-minutes with that overhead in mind.
|
|
389
|
+
//
|
|
390
|
+
// gh exit codes for `gh pr checks <branch>`:
|
|
391
|
+
// 0 — all required checks passed
|
|
392
|
+
// 8 — checks still pending (not all completed)
|
|
393
|
+
// anything else — at least one required check failed
|
|
394
|
+
//
|
|
395
|
+
// On non-github platforms (or when CLI is missing), exits 2 (SKIPPED) so
|
|
396
|
+
// land.js can surface a user_prompt rather than blocking on a feature
|
|
397
|
+
// we can't deliver.
|
|
398
|
+
async function runChecksMode({ platform, branch, baseBranch, waitMinutes, requireApprovedReview, baseUrl }) {
|
|
399
|
+
// `platform` has already been resolved (auto → concrete) in main(),
|
|
400
|
+
// so we only branch on concrete provider strings here.
|
|
401
|
+
if (platform === 'github') {
|
|
402
|
+
if (!(await hasCli('gh'))) {
|
|
403
|
+
log.err('WARN: gh CLI not found, cannot poll PR checks');
|
|
404
|
+
log.out('SKIPPED');
|
|
405
|
+
process.exit(2);
|
|
406
|
+
}
|
|
407
|
+
const deadline = Date.now() + Math.max(0, waitMinutes) * 60_000;
|
|
408
|
+
let lastSummary = '';
|
|
409
|
+
while (Date.now() < deadline) {
|
|
410
|
+
const r = await tryRun('gh', ['pr', 'checks', branch], { timeoutMs: 30_000 });
|
|
411
|
+
lastSummary = (r.stdout || '').trim().split('\n').slice(0, 5).join('\n');
|
|
412
|
+
if (r.exitCode === 0) {
|
|
413
|
+
// All required checks passed. If review is required, poll for that too.
|
|
414
|
+
if (!requireApprovedReview) {
|
|
415
|
+
log.out(`checks passed for ${branch}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const reviewOk = await pollReviewApproved(branch, deadline);
|
|
419
|
+
if (reviewOk) {
|
|
420
|
+
log.out(`checks passed + review approved for ${branch}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
log.error(`checks passed but review not approved before deadline for ${branch}`);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
if (r.exitCode === 8) {
|
|
427
|
+
// Pending — wait and retry. Add ±5s jitter so concurrent
|
|
428
|
+
// autopilot sessions (e.g. ma.parallel_stories) don't pile up
|
|
429
|
+
// gh-API calls in lockstep every 30 seconds.
|
|
430
|
+
await sleep(jitteredInterval(30_000, 5_000));
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
// Hard failure (e.g. exit 1) — at least one required check failed.
|
|
434
|
+
log.error(`checks failed for ${branch}:\n${lastSummary}`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
log.error(`timed out after ${waitMinutes}m waiting for checks on ${branch}\n${lastSummary}`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (platform === 'gitlab' || platform === 'bitbucket' || platform === 'gitea' || platform === 'git_only') {
|
|
442
|
+
// Polling is not yet implemented for these providers. Surface a
|
|
443
|
+
// SKIPPED exit so land.js can prompt the user.
|
|
444
|
+
log.err(`INFO: --mode checks polling not yet implemented for ${platform}. Verify manually.`);
|
|
445
|
+
log.out('SKIPPED');
|
|
446
|
+
process.exit(2);
|
|
447
|
+
}
|
|
448
|
+
|
|
307
449
|
log.error(`unknown platform '${platform}'`);
|
|
308
450
|
process.exit(1);
|
|
309
451
|
}
|
|
310
452
|
|
|
453
|
+
async function pollReviewApproved(branch, deadline) {
|
|
454
|
+
while (Date.now() < deadline) {
|
|
455
|
+
const r = await tryRun(
|
|
456
|
+
'gh',
|
|
457
|
+
['pr', 'view', branch, '--json', 'reviewDecision', '--jq', '.reviewDecision'],
|
|
458
|
+
{ timeoutMs: 15_000 },
|
|
459
|
+
);
|
|
460
|
+
const decision = (r.stdout || '').trim();
|
|
461
|
+
if (decision === 'APPROVED') return true;
|
|
462
|
+
if (decision === 'CHANGES_REQUESTED') return false; // hard fail — no point waiting
|
|
463
|
+
// REVIEW_REQUIRED, empty string, or any other state → keep polling
|
|
464
|
+
// with the same ±5s jitter as the checks loop.
|
|
465
|
+
await sleep(jitteredInterval(30_000, 5_000));
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function sleep(ms) {
|
|
471
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Compute a polling interval with ±jitterMs of uniform random noise so
|
|
475
|
+
// concurrent pollers (parallel autopilot sessions) don't hit gh's API
|
|
476
|
+
// in lockstep.
|
|
477
|
+
function jitteredInterval(baseMs, jitterMs) {
|
|
478
|
+
const delta = Math.floor((Math.random() - 0.5) * 2 * jitterMs);
|
|
479
|
+
return Math.max(1000, baseMs + delta);
|
|
480
|
+
}
|
|
481
|
+
|
|
311
482
|
// Export pure helpers so they can be unit-tested directly. The script
|
|
312
483
|
// itself still runs `main()` when invoked as a module.
|
|
313
484
|
module.exports = { parseGitRemote, redactAuth };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* run-step.js — canonical executor for a single planned step.
|
|
4
|
+
*
|
|
5
|
+
* Reads a step JSON from stdin (or --step-file <path>) and runs it
|
|
6
|
+
* honoring the documented metadata contract from workflow.orchestrator.md:
|
|
7
|
+
*
|
|
8
|
+
* - args: string[] — argv (no shell interpolation)
|
|
9
|
+
* - description?: string — for logs only
|
|
10
|
+
* - env?: { [k]: string } — merged into process.env for the
|
|
11
|
+
* step's lifetime
|
|
12
|
+
* - retry?: { attempts, backoff_ms: [...], on: 'network'|'never' }
|
|
13
|
+
* — re-run on failure. `attempts`
|
|
14
|
+
* counts ATTEMPTS including the
|
|
15
|
+
* first; backoff_ms is consulted
|
|
16
|
+
* between retries (using
|
|
17
|
+
* backoff_ms[i] for attempt i+1, or
|
|
18
|
+
* the last value if out of range).
|
|
19
|
+
* `on: 'never'` disables retry
|
|
20
|
+
* regardless of attempts.
|
|
21
|
+
* - tolerate_exit_codes?: number[]
|
|
22
|
+
* — non-zero exit codes treated as
|
|
23
|
+
* success (idempotency for ops like
|
|
24
|
+
* gh pr merge / create-pr.js SKIPPED)
|
|
25
|
+
* - optional?: boolean — non-zero exit logged as warning,
|
|
26
|
+
* runner still exits 0 so the caller
|
|
27
|
+
* continues to the next step
|
|
28
|
+
* - timeout_ms?: number — per-attempt timeout
|
|
29
|
+
*
|
|
30
|
+
* Exit semantics:
|
|
31
|
+
* 0 — step succeeded (real success OR tolerate match OR optional fail)
|
|
32
|
+
* N — actual exit code of the final attempt, when neither
|
|
33
|
+
* tolerate_exit_codes nor optional applies
|
|
34
|
+
*
|
|
35
|
+
* Why this exists: the workflow contract used to assume the LLM reads
|
|
36
|
+
* step metadata fields and honors them. That coupling let drift creep
|
|
37
|
+
* in (e.g. tolerate_exit_codes silently ignored, optional treated as
|
|
38
|
+
* fatal). A small Node executor is the source of truth so the LLM
|
|
39
|
+
* doesn't need to remember the rules — it just runs
|
|
40
|
+
* `node _Sprintpilot/scripts/run-step.js --step-file <tmpfile>` per
|
|
41
|
+
* step and inspects exit code.
|
|
42
|
+
*
|
|
43
|
+
* Signal handling: SIGINT/SIGTERM received by run-step are forwarded
|
|
44
|
+
* to the in-flight child (when one is alive) so Ctrl-C terminates
|
|
45
|
+
* the chain cleanly rather than orphaning long-running `gh`/`git`
|
|
46
|
+
* subprocesses.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* echo '{"args":["git","status"]}' | node run-step.js
|
|
50
|
+
* node run-step.js --step-file /tmp/step.json
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
'use strict';
|
|
54
|
+
|
|
55
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
56
|
+
const fs = require('node:fs');
|
|
57
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
58
|
+
|
|
59
|
+
function readStepJson(opts) {
|
|
60
|
+
if (opts['step-file']) return fs.readFileSync(opts['step-file'], 'utf8');
|
|
61
|
+
return fs.readFileSync(0, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Run a single attempt of the step's argv. Returns { exitCode, error }.
|
|
69
|
+
// stdin: 'ignore' so the subprocess doesn't inherit run-step's stdin
|
|
70
|
+
// (which is at EOF after readStepJson consumed it) — a command that
|
|
71
|
+
// reads stdin (e.g. `git commit --file=-`) would otherwise see an
|
|
72
|
+
// immediate EOF and silently produce nothing.
|
|
73
|
+
function runOnce(cmd, rest, env, timeoutMs, currentChildRef) {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const child = spawn(cmd, rest, {
|
|
76
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
77
|
+
env,
|
|
78
|
+
timeout: timeoutMs,
|
|
79
|
+
});
|
|
80
|
+
currentChildRef.child = child;
|
|
81
|
+
child.on('error', (err) => {
|
|
82
|
+
currentChildRef.child = null;
|
|
83
|
+
resolve({ exitCode: 2, error: err });
|
|
84
|
+
});
|
|
85
|
+
child.on('close', (code, signal) => {
|
|
86
|
+
currentChildRef.child = null;
|
|
87
|
+
if (signal) {
|
|
88
|
+
// Killed by signal — treat as non-zero. spawn maps signal name
|
|
89
|
+
// to no exit code, so synthesize one (128 + signal-number ish).
|
|
90
|
+
resolve({ exitCode: 130, error: null, signal });
|
|
91
|
+
} else {
|
|
92
|
+
resolve({ exitCode: typeof code === 'number' ? code : 2, error: null });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function backoffFor(attemptIndex, backoffMs) {
|
|
99
|
+
if (!Array.isArray(backoffMs) || backoffMs.length === 0) return 0;
|
|
100
|
+
const idx = Math.min(attemptIndex, backoffMs.length - 1);
|
|
101
|
+
return Math.max(0, Number(backoffMs[idx]) || 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runStep(step) {
|
|
105
|
+
const [cmd, ...rest] = step.args;
|
|
106
|
+
// Merge env: process.env first so unspecified keys stay; step.env
|
|
107
|
+
// wins for overlapping keys. Explicit non-null + non-array check
|
|
108
|
+
// because `typeof null === 'object'` and `typeof [] === 'object'`
|
|
109
|
+
// would both pass a naive `typeof === 'object'` guard, leading to
|
|
110
|
+
// `{...null}` (empty merge) or `{...[]}` (drops env entirely).
|
|
111
|
+
const env =
|
|
112
|
+
step.env !== null &&
|
|
113
|
+
step.env !== undefined &&
|
|
114
|
+
typeof step.env === 'object' &&
|
|
115
|
+
!Array.isArray(step.env)
|
|
116
|
+
? { ...process.env, ...step.env }
|
|
117
|
+
: process.env;
|
|
118
|
+
|
|
119
|
+
if (step.description) {
|
|
120
|
+
process.stderr.write(`[run-step] ${step.description}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const retry = step.retry || {};
|
|
124
|
+
const retryEnabled = retry && retry.on && retry.on !== 'never';
|
|
125
|
+
const maxAttempts = retryEnabled && Number.isInteger(retry.attempts) && retry.attempts > 0
|
|
126
|
+
? retry.attempts
|
|
127
|
+
: 1;
|
|
128
|
+
const backoffMs = retryEnabled ? retry.backoff_ms : null;
|
|
129
|
+
const timeoutMs = typeof step.timeout_ms === 'number' ? step.timeout_ms : undefined;
|
|
130
|
+
const tolerated = Array.isArray(step.tolerate_exit_codes) ? step.tolerate_exit_codes : [];
|
|
131
|
+
|
|
132
|
+
const childRef = { child: null };
|
|
133
|
+
const forwardSignal = (sig) => () => {
|
|
134
|
+
if (childRef.child && !childRef.child.killed) {
|
|
135
|
+
try {
|
|
136
|
+
childRef.child.kill(sig);
|
|
137
|
+
} catch (_e) {
|
|
138
|
+
/* best-effort */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
process.exit(130);
|
|
142
|
+
};
|
|
143
|
+
process.on('SIGINT', forwardSignal('SIGINT'));
|
|
144
|
+
process.on('SIGTERM', forwardSignal('SIGTERM'));
|
|
145
|
+
|
|
146
|
+
let lastExit = 0;
|
|
147
|
+
let lastError = null;
|
|
148
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
149
|
+
if (attempt > 0) {
|
|
150
|
+
const wait = backoffFor(attempt - 1, backoffMs);
|
|
151
|
+
if (wait > 0) {
|
|
152
|
+
process.stderr.write(`[run-step] retry attempt ${attempt + 1}/${maxAttempts} after ${wait}ms\n`);
|
|
153
|
+
await sleep(wait);
|
|
154
|
+
} else {
|
|
155
|
+
process.stderr.write(`[run-step] retry attempt ${attempt + 1}/${maxAttempts}\n`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const r = await runOnce(cmd, rest, env, timeoutMs, childRef);
|
|
159
|
+
lastExit = r.exitCode;
|
|
160
|
+
lastError = r.error;
|
|
161
|
+
if (lastError && step.optional) {
|
|
162
|
+
process.stderr.write(`[run-step] WARN optional step failed to launch: ${lastError.message}\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
if (lastError) {
|
|
166
|
+
process.stderr.write(`run-step: spawn error: ${lastError.message}\n`);
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
if (lastExit === 0 || tolerated.includes(lastExit)) {
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
// Non-zero and not tolerated. Retry policy `on: 'network'` is a
|
|
173
|
+
// declared intent — we re-run for any failure since we can't tell
|
|
174
|
+
// a network error from a logic error by exit code alone. The
|
|
175
|
+
// orchestrator's adapt.js classifies failure kinds afterwards.
|
|
176
|
+
}
|
|
177
|
+
if (step.optional) {
|
|
178
|
+
process.stderr.write(`[run-step] WARN optional step exited ${lastExit}; continuing\n`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
return lastExit;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function main() {
|
|
185
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
186
|
+
let raw;
|
|
187
|
+
try {
|
|
188
|
+
raw = readStepJson(opts);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
process.stderr.write(`run-step: cannot read step JSON: ${e.message}\n`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let step;
|
|
195
|
+
try {
|
|
196
|
+
step = JSON.parse(raw);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
process.stderr.write(`run-step: invalid JSON: ${e.message}\n`);
|
|
199
|
+
process.exit(2);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!step || !Array.isArray(step.args) || step.args.length === 0) {
|
|
203
|
+
process.stderr.write('run-step: step.args (non-empty array) required\n');
|
|
204
|
+
process.exit(2);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const code = await runStep(step);
|
|
208
|
+
process.exit(code);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (require.main === module) {
|
|
212
|
+
main().catch((e) => {
|
|
213
|
+
process.stderr.write(`run-step: ${e.stack || e.message || String(e)}\n`);
|
|
214
|
+
process.exit(2);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { main, runStep };
|
|
219
|
+
// Keep spawnSync import alive in case external callers use it (no-op
|
|
220
|
+
// reference for tooling that prunes unused imports).
|
|
221
|
+
void spawnSync;
|
|
@@ -33,12 +33,45 @@ orchestrator emits it.
|
|
|
33
33
|
|-------------------|--------------------------------------------------------------------------------------------------|
|
|
34
34
|
| `invoke_skill` | Run the named BMad skill **verbatim from its own body** (e.g. `bmad-create-story`, `bmad-quick-dev`, `bmad-code-review`). `action.template_slots` is a parameter bag (story_key, prior_diagnosis, relevant_decisions, prior_signals_summary, …) — it's input context for BMad's skill, NOT a replacement for the skill's instructions. When `implementation_flow=quick`, you'll receive `invoke_skill: bmad-quick-dev` per story — follow BMad's `step-oneshot.md`. |
|
|
35
35
|
| `run_script` | Execute `action.command` via the host's shell-equivalent. Argv-only — no shell interpolation. |
|
|
36
|
-
| `git_op` | Execute `action.steps` in order. The orchestrator pre-plans every git op (commit_and_push_story, merge_epic, push, fetch, create_branch) via `git-plan.js` and inlines the resulting argv sequence — each step has `args: [cmd, ...argv]`, a `description`, and
|
|
36
|
+
| `git_op` | Execute `action.steps` in order. The orchestrator pre-plans every git op (commit_and_push_story, merge_epic, push, fetch, create_branch) via `git-plan.js` and inlines the resulting argv sequence — each step has `args: [cmd, ...argv]`, a `description`, and optional metadata fields (see below). **Required**: run each step via `_Sprintpilot/scripts/run-step.js` (see "Step metadata" below) so the metadata contract is enforced uniformly. Argv-only — NO shell interpolation. Halt on first non-retryable failure. Never improvise the git commands or skip a step — `git push` lives in `steps`, not in `op`. Empty `steps: []` (e.g. when `git.enabled: false`) means "no work, signal success." |
|
|
37
37
|
| `parallel_batch` | Dispatch each child action concurrently (M6+ hosts only — fall back to sequential otherwise). |
|
|
38
38
|
| `user_prompt` | Ask the user `action.prompt`. Pass the answer back via `user_input` signal. |
|
|
39
39
|
| `halt` | Stop. Honor `action.handoff: 'sprint_finalize_pending'` by ending the session cleanly. |
|
|
40
40
|
| `noop` | Re-loop (state machine advancing without an external effect). |
|
|
41
41
|
|
|
42
|
+
### Step metadata (git_op / run_script)
|
|
43
|
+
|
|
44
|
+
Each step in `action.steps` may carry these optional fields. They are
|
|
45
|
+
NOT defaults — only honor them when present:
|
|
46
|
+
|
|
47
|
+
| Field | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `retry` | `{ attempts: N, backoff_ms: [...], on: 'network' \| 'never' }`. Retry transient errors per the policy. `on: 'network'` covers e.g. `git push` to a flaky remote. |
|
|
50
|
+
| `optional: true` | Run the step; on non-zero exit, log a warning and **continue** to the next step rather than halting. Used for best-effort prefetches and pulls. |
|
|
51
|
+
| `tolerate_exit_codes: [N, M, ...]` | Treat any of these exit codes as success (equivalent to exit 0 for halt-detection). Used for idempotent commands like `gh pr merge` (which exits non-zero when the PR is already merged) and `create-pr.js` (which returns exit 2 SKIPPED when the platform CLI is unavailable). |
|
|
52
|
+
| `env: { KEY: "value", ... }` | Set environment variables for this step's process only (merged on top of inherited env). Used to target self-hosted platform instances: `GH_HOST` for GitHub Enterprise, `GITLAB_URI` for self-hosted GitLab. |
|
|
53
|
+
| `description` | Human-readable summary, surface in your own logs. |
|
|
54
|
+
|
|
55
|
+
**Use the runner — direct argv execution is not equivalent.** A step
|
|
56
|
+
that carries ANY of `retry`, `optional`, `tolerate_exit_codes`, or
|
|
57
|
+
`env` MUST be executed via `_Sprintpilot/scripts/run-step.js`. The
|
|
58
|
+
runner is the source of truth for the metadata contract; honoring
|
|
59
|
+
those fields by hand drifts and loses retries, env scoping, and
|
|
60
|
+
exit-code tolerance. Direct execution is only acceptable for steps
|
|
61
|
+
that have none of those fields.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
echo '<step-json>' | node _Sprintpilot/scripts/run-step.js
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Path resolution: the orchestrator runs from the project root, so the
|
|
68
|
+
relative path `_Sprintpilot/scripts/run-step.js` is correct in normal
|
|
69
|
+
invocations. If running from a different cwd (e.g. a worktree
|
|
70
|
+
subdirectory), resolve the absolute path from the autopilot's
|
|
71
|
+
`--project-root` argument.
|
|
72
|
+
|
|
73
|
+
When you signal `success` after a `git_op`, include `git_steps_completed: true` only if every step ran via the runner (or hand-executed in a way equivalent to it) and either exited 0 or matched its `tolerate_exit_codes`. A step that needed `optional: true` to pass still counts as not-completed for stricter sub-steps' purposes; `git_steps_completed` reflects the strict run.
|
|
74
|
+
|
|
42
75
|
## Signals you emit
|
|
43
76
|
|
|
44
77
|
Wrap everything in `{ "status": "...", ... }` and pass to
|
|
@@ -90,6 +123,7 @@ bookkeeping you'd otherwise be tempted to skip:
|
|
|
90
123
|
|
|
91
124
|
| Phase | Bookkeeping that MUST be true before you signal `success` |
|
|
92
125
|
|---|---|
|
|
126
|
+
| `prepare_story_branch` | Every step in `action.steps` exited 0 — HEAD is on `action.branch` (verify with `git rev-parse --abbrev-ref HEAD`). Emitted only when `git.granularity ∈ {story, epic}` AND `git.reuse_user_branch=false`. Under `reuse_user_branch=true` this phase is skipped — the user-locked branch is detected at cmdStart instead. |
|
|
93
127
|
| `create_story` | Story file has `## Acceptance Criteria` (≥1 bullet) AND a `## Tasks` (or `## Tasks/Subtasks`) section with at least one `[ ]` or `[x]` checkbox. |
|
|
94
128
|
| `dev_red` / `dev_green` | Test files exist on disk; runner exit codes match the phase contract; `tests_run` matches the runner's count. |
|
|
95
129
|
| `code_review` | `_bmad-output/reviews/<story_key>.md` exists; `findings[]` carries `{id, severity, category, action: 'block'\|'patch'\|'defer', rationale}` for every finding. |
|
package/package.json
CHANGED