@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.
@@ -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), { booleanFlags: ['dry-run'] });
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 (platform === 'git_only') {
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 (platform === 'github') {
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 (platform === 'gitlab') {
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 (platform === 'bitbucket') {
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 (platform === 'gitea') {
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 an optional `retry` policy. Run each step's argv verbatim (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`. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {