@ikunin/sprintpilot 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,17 @@ npx @ikunin/sprintpilot@latest
31
31
  /sprint-autopilot-on
32
32
  ```
33
33
 
34
+ **Start at a specific story or epic** (v2.2.0+):
35
+
36
+ ```
37
+ /sprint-autopilot-on epic 4
38
+ /sprint-autopilot-on stories 3.1, 3.2, 4.5
39
+ /sprint-autopilot-on 4-8-realm-wide-matcher-and-session-lock
40
+ /sprint-autopilot-on voice identity matcher
41
+ ```
42
+
43
+ The skill resolves the natural-language directive against your `sprint-status.yaml` and queues the matching stories. The autopilot runs them in order, then falls back to the normal next-pending-story flow. Ambiguous matches surface a candidate list — never picks arbitrarily.
44
+
34
45
  Non-interactive install:
35
46
 
36
47
  ```bash
@@ -65,6 +65,25 @@ function help() {
65
65
  ' --profile <nano|small|medium|large|legacy>',
66
66
  ' Override resolved profile',
67
67
  ' --help Show this help',
68
+ '',
69
+ 'Story-selection flags (on `start` only):',
70
+ ' --stories <k1,k2,...> Explicit queue of story keys to run, in',
71
+ ' order. Keys must exist in sprint-status.yaml',
72
+ ' and not be already done. Once the queue',
73
+ ' exhausts, the orchestrator falls back to',
74
+ ' its normal next-pending-story flow.',
75
+ ' --epic <id> Queue all non-done stories of the given',
76
+ ' epic (id matches `epic-N` or bare `N`),',
77
+ ' in sprint-status.yaml order. --stories',
78
+ ' takes priority when both are given.',
79
+ ' --force Overwrite an in-flight queue. Without',
80
+ ' this, --stories/--epic refuses to run',
81
+ ' when current_story is set or a queue',
82
+ ' already exists.',
83
+ '',
84
+ 'Natural-language entry: `/sprint-autopilot-on epic 4` /',
85
+ '`/sprint-autopilot-on stories 3.1, 4.5` — the skill resolves the NL',
86
+ 'directive to canonical keys and invokes `autopilot start --stories`.',
68
87
  ].join('\n'),
69
88
  );
70
89
  }
@@ -169,6 +188,125 @@ function looksLikeStoryKey(key) {
169
188
  return withoutEpicPrefix.includes('-');
170
189
  }
171
190
 
191
+ // Build an explicit story queue from CLI opts (--stories / --epic).
192
+ // Returns { queue: [], error?: string }. Either or both flags can be
193
+ // provided; --stories is the canonical list and --epic expands to all
194
+ // non-done stories under that epic. When both are given, --stories
195
+ // takes priority. When neither is given, returns an empty queue
196
+ // (orchestrator falls back to resolveNextStoryKey).
197
+ //
198
+ // Validation:
199
+ // - Every key listed in --stories must exist in sprint-status.yaml.
200
+ // - Every key must NOT have status 'done'.
201
+ // - For --epic, the epic must have at least one non-done story.
202
+ function buildExplicitQueueFromOpts(opts, projectRoot) {
203
+ const rawStories = typeof opts.stories === 'string' ? opts.stories : null;
204
+ const rawEpic = opts.epic !== undefined && opts.epic !== null ? String(opts.epic) : null;
205
+ if (!rawStories && !rawEpic) return { queue: [] };
206
+
207
+ const sprintStories = readSprintStatuses(projectRoot);
208
+ if (!sprintStories || Object.keys(sprintStories).length === 0) {
209
+ return {
210
+ queue: [],
211
+ error:
212
+ '--stories / --epic given but sprint-status.yaml is missing or empty. ' +
213
+ 'Run BMad sprint-planning to populate it before queuing stories.',
214
+ };
215
+ }
216
+
217
+ if (rawStories) {
218
+ const requested = rawStories
219
+ .split(',')
220
+ .map((s) => s.trim())
221
+ .filter(Boolean);
222
+ if (requested.length === 0) {
223
+ return { queue: [], error: '--stories was empty after parsing the comma-separated list.' };
224
+ }
225
+ const missing = [];
226
+ const alreadyDone = [];
227
+ const queue = [];
228
+ for (const key of requested) {
229
+ if (!Object.prototype.hasOwnProperty.call(sprintStories, key)) {
230
+ missing.push(key);
231
+ continue;
232
+ }
233
+ const status = String(sprintStories[key].status || '').trim().toLowerCase();
234
+ if (status === 'done') {
235
+ alreadyDone.push(key);
236
+ continue;
237
+ }
238
+ queue.push(key);
239
+ }
240
+ if (missing.length > 0 || alreadyDone.length > 0) {
241
+ const parts = [];
242
+ if (missing.length > 0) {
243
+ parts.push(
244
+ `not in sprint-status.yaml: ${missing.join(', ')}. ` +
245
+ 'Use canonical keys (e.g. 4-8-realm-wide-matcher), not story numbers (e.g. 4.8).',
246
+ );
247
+ }
248
+ if (alreadyDone.length > 0) {
249
+ parts.push(`already done: ${alreadyDone.join(', ')}`);
250
+ }
251
+ return { queue: [], error: `--stories rejected: ${parts.join(' | ')}` };
252
+ }
253
+ return { queue };
254
+ }
255
+
256
+ // --epic only
257
+ const expanded = resolveStoriesForEpic(projectRoot, rawEpic);
258
+ if (expanded.length === 0) {
259
+ return {
260
+ queue: [],
261
+ error: `--epic ${rawEpic}: no non-done stories found in sprint-status.yaml`,
262
+ };
263
+ }
264
+ return { queue: expanded };
265
+ }
266
+
267
+ // Read and parse sprint-status.yaml. Returns { stories } where stories
268
+ // is a map of {key: {status: string|null}}. Returns null on any failure
269
+ // (missing file, parse error). Callers handle null gracefully.
270
+ function readSprintStatuses(projectRoot) {
271
+ if (!projectRoot) return null;
272
+ const p = path.join(
273
+ projectRoot,
274
+ '_bmad-output',
275
+ 'implementation-artifacts',
276
+ 'sprint-status.yaml',
277
+ );
278
+ if (!safeExistsSync(p)) return null;
279
+ try {
280
+ const raw = fs.readFileSync(p, 'utf8');
281
+ return parseSprintStatuses(raw);
282
+ } catch (_e) {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ // Resolve all non-done story keys for the given epic id, in
288
+ // sprint-status.yaml insertion order. Used by `autopilot start
289
+ // --epic <id>` to expand into an explicit queue. Returns [] when:
290
+ // - sprint-status doesn't exist or fails to parse
291
+ // - no stories match the epic
292
+ // - all matching stories are already done
293
+ function resolveStoriesForEpic(projectRoot, epicId) {
294
+ if (!epicId) return [];
295
+ const stories = readSprintStatuses(projectRoot);
296
+ if (!stories) return [];
297
+ const keys = Object.keys(stories);
298
+ const out = [];
299
+ for (const key of keys) {
300
+ if (!looksLikeStoryKey(key)) continue;
301
+ const derivedEpic = deriveEpicFromStoryKey(key);
302
+ if (derivedEpic !== epicId && derivedEpic !== `epic-${epicId}`) continue;
303
+ const status = String(stories[key].status || '').trim().toLowerCase();
304
+ if (status === 'done') continue;
305
+ out.push(key);
306
+ }
307
+ return out;
308
+ }
309
+
172
310
  // Derive the epic identifier from a BMad story key. Convention:
173
311
  // `epic-N-slug` → `epic-N`; `<epic>-<story>-<slug>` → `<epic>`.
174
312
  // Returns null when the key doesn't parse cleanly. Kept in sync with
@@ -284,6 +422,20 @@ function composeRuntimeState(persisted, profile, projectRoot) {
284
422
  let resolvedStoryKey = persisted.current_story || null;
285
423
  let resolvedEpic = persisted.current_epic || null;
286
424
  let resolvedStoryFilePath = persisted.story_file_path || null;
425
+ // Explicit queue (populated by `autopilot start --stories` / `--epic`)
426
+ // takes priority over the linear resolveNextStoryKey scan: when a
427
+ // user specifies "start with stories 4-1, 4-2, 4-5" we honor that
428
+ // order regardless of what comes first in sprint-status.yaml.
429
+ // Forward-compat for ma.parallel_stories: the queue is the source
430
+ // multiple workers will pull from when the parallel-batch path is
431
+ // wired into the state machine.
432
+ const persistedQueue = Array.isArray(persisted.story_queue)
433
+ ? persisted.story_queue.filter((k) => typeof k === 'string' && k.length > 0)
434
+ : [];
435
+ if (!resolvedStoryKey && persistedQueue.length > 0) {
436
+ resolvedStoryKey = persistedQueue[0];
437
+ if (!resolvedEpic) resolvedEpic = deriveEpicFromStoryKey(resolvedStoryKey);
438
+ }
287
439
  if (phase === STATES.PREPARE_STORY_BRANCH && !resolvedStoryKey) {
288
440
  const next = resolveNextStoryKey(projectRoot);
289
441
  if (next) {
@@ -317,6 +469,10 @@ function composeRuntimeState(persisted, profile, projectRoot) {
317
469
  escalation_note: persisted.escalation_note || null,
318
470
  // Branch reuse: persisted across resumes once detected on first boot.
319
471
  user_branch: persisted.user_branch || null,
472
+ // Explicit story queue from `autopilot start --stories` / `--epic`.
473
+ // Head is the current pick; adapt.advanceState pops on story
474
+ // completion. Empty array means "no override; use resolveNextStoryKey."
475
+ story_queue: persistedQueue,
320
476
  // Land-as-you-go: pending land state survives rebase-conflict halts.
321
477
  land_pending: persisted.land_pending || null,
322
478
  // Pending alternative (propose_alternative → user_prompt) survives
@@ -348,6 +504,7 @@ function persistRuntimeState(runtime, profile, projectRoot) {
348
504
  verify_reject_count: runtime.verify_reject_count,
349
505
  consecutive_test_failures: runtime.consecutive_test_failures,
350
506
  user_branch: runtime.user_branch,
507
+ story_queue: Array.isArray(runtime.story_queue) ? runtime.story_queue : [],
351
508
  land_pending: runtime.land_pending,
352
509
  pending_alternative: runtime.pending_alternative || null,
353
510
  };
@@ -654,6 +811,37 @@ function cmdStart(opts) {
654
811
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
655
812
  const persisted = loadState(projectRoot);
656
813
 
814
+ // Build an explicit story queue from --stories / --epic flags. The
815
+ // user (or the LLM via /sprint-autopilot-on natural-language args)
816
+ // tells the orchestrator EXACTLY which stories to run, and in what
817
+ // order. Queue head is consumed first; resolveNextStoryKey takes
818
+ // over once the queue exhausts.
819
+ const queueBuildResult = buildExplicitQueueFromOpts(opts, projectRoot);
820
+ if (queueBuildResult.error) {
821
+ log.error(queueBuildResult.error);
822
+ process.stdout.write(
823
+ `${JSON.stringify({ error: queueBuildResult.error, kind: 'queue_validation_error' }, null, 2)}\n`,
824
+ );
825
+ return 2;
826
+ }
827
+ const explicitQueue = queueBuildResult.queue; // may be []
828
+
829
+ // Mid-sprint guard: refuse to overwrite an in-flight queue without
830
+ // --force. The user almost certainly wants to finish what's running
831
+ // before pivoting; a silent overwrite would lose state.
832
+ if (
833
+ explicitQueue.length > 0 &&
834
+ (persisted.current_story || (persisted.story_queue || []).length > 0) &&
835
+ !opts.force
836
+ ) {
837
+ const err =
838
+ `Sprint already in progress (current_story=${persisted.current_story || '<queue head>'}). ` +
839
+ `Pass --force to overwrite the queue, or finish the current story first.`;
840
+ log.error(err);
841
+ process.stdout.write(`${JSON.stringify({ error: err, kind: 'mid_sprint_queue_overwrite' }, null, 2)}\n`);
842
+ return 2;
843
+ }
844
+
657
845
  // Resume detection: if a prior session left a fingerprint, diff.
658
846
  const lastHalt = ledger.last({ projectRoot }, 'halt');
659
847
  if (lastHalt && lastHalt.fingerprint) {
@@ -670,6 +858,25 @@ function cmdStart(opts) {
670
858
  }
671
859
  }
672
860
 
861
+ // Persist the new queue BEFORE composing runtime state so the queue
862
+ // head is visible to composeRuntimeState's resolver.
863
+ if (explicitQueue.length > 0) {
864
+ persisted.story_queue = explicitQueue;
865
+ // --force overwrite also clears the prior story identity so the
866
+ // queue head is selected cleanly. Without this, persisted.current_
867
+ // story would short-circuit the queue read.
868
+ if (opts.force) {
869
+ persisted.current_story = null;
870
+ persisted.story_file_path = null;
871
+ persisted.current_epic = null;
872
+ persisted.current_bmad_step = null;
873
+ }
874
+ ledger.append(
875
+ { kind: 'story_queue_set', queue: explicitQueue, force: !!opts.force },
876
+ { projectRoot },
877
+ );
878
+ }
879
+
673
880
  // Fresh start or clean resume. `composeRuntimeState` applies the
674
881
  // profile-aware initial phase when persisted state is empty.
675
882
  const runtime = composeRuntimeState(persisted, profile, projectRoot);
@@ -886,7 +1093,7 @@ function cmdStatus(opts) {
886
1093
  // ------------------------------------------------------------ main
887
1094
 
888
1095
  function main(argv) {
889
- const { opts, positional } = parseArgs(argv, { booleanFlags: ['help'] });
1096
+ const { opts, positional } = parseArgs(argv, { booleanFlags: ['help', 'force'] });
890
1097
  if (opts.help) {
891
1098
  help();
892
1099
  return 0;
@@ -35,6 +35,10 @@ const VALID_KINDS = [
35
35
  'lock_acquired',
36
36
  'lock_released',
37
37
  'flush',
38
+ // Explicit story queue installed via `autopilot start --stories` /
39
+ // `--epic`. Logged once per start invocation so resume/audit can see
40
+ // why a queue head differs from sprint-status's natural order.
41
+ 'story_queue_set',
38
42
  ];
39
43
 
40
44
  function isPlainObject(v) {
@@ -548,6 +548,23 @@ function advanceState(state, profile, newPhase, signal) {
548
548
  }
549
549
  }
550
550
 
551
+ // Story-completion boundary: STORY_DONE → EPIC_BOUNDARY_CHECK means
552
+ // the current story is committed and pushed. Pop the explicit story
553
+ // queue (if any) — its head was THIS story — and clear story_key so
554
+ // composeRuntimeState picks queue[1] (now queue[0]) on the next
555
+ // emission. Without this pop, composeRuntimeState would re-pick the
556
+ // just-completed story (via the signal-output propagation above) and
557
+ // loop. This block runs AFTER propagation so the clearing wins.
558
+ if (state.phase === STATES.STORY_DONE && newPhase === STATES.EPIC_BOUNDARY_CHECK) {
559
+ if (Array.isArray(state.story_queue) && state.story_queue.length > 0) {
560
+ next.story_queue = state.story_queue.slice(1);
561
+ }
562
+ next.story_key = null;
563
+ next.story_file_path = null;
564
+ next.current_epic = null;
565
+ next.ac_summary = null;
566
+ }
567
+
551
568
  return next;
552
569
  }
553
570
 
@@ -27,6 +27,11 @@ const CRITICAL_KEYS = new Set([
27
27
  'current_bmad_step',
28
28
  'in_worktree',
29
29
  'patch_commits',
30
+ // Explicit story queue populated by `autopilot start --stories <csv>`
31
+ // / `--epic <id>`. composeRuntimeState reads queue[0] as the next
32
+ // story_key; adapt.advanceState pops the head when a story completes.
33
+ // When empty, the orchestrator falls back to resolveNextStoryKey.
34
+ 'story_queue',
30
35
  ]);
31
36
 
32
37
  // In-memory pending buffer. Process-scoped — flushed at story boundary or
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.1.5
3
+ version: 2.2.0
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
@@ -26,3 +26,87 @@ Follow **`./workflow.orchestrator.md`** verbatim. Flow control lives in
26
26
 
27
27
  `workflow.orchestrator.md` is the **sole authority** for the rest of the
28
28
  session.
29
+
30
+ ---
31
+
32
+ ## Natural-language entry: starting at a specific story or epic
33
+
34
+ The user may invoke this skill with extra arguments specifying which
35
+ story / epic to run, e.g.:
36
+
37
+ - `/sprint-autopilot-on epic 4`
38
+ - `/sprint-autopilot-on stories 3.1, 3.2, 4.5`
39
+ - `/sprint-autopilot-on 4-8-realm-wide-matcher`
40
+ - `/sprint-autopilot-on voice identity matcher`
41
+ - `/sprint-autopilot-on starting from 4.5`
42
+
43
+ Translate the natural-language directive into an explicit queue of
44
+ canonical story keys, then call `autopilot start --stories <csv>` (or
45
+ `--epic <id>`). The orchestrator validates the keys before running.
46
+
47
+ **Resolution procedure** — do this BEFORE the first `autopilot next`:
48
+
49
+ 1. **Read sprint-status.yaml** at `_bmad-output/implementation-artifacts/sprint-status.yaml`.
50
+ If it's missing, tell the user to run BMad sprint-planning first and
51
+ stop — don't invoke `autopilot start`.
52
+
53
+ 2. **Parse the user's directive.** Match against these forms:
54
+
55
+ | User says | Resolve to |
56
+ |---|---|
57
+ | "epic 4", "epic-4", "all of epic 4" | `autopilot start --epic 4` |
58
+ | "stories 3.1, 3.2, 4.5", "3.1 3.2 4.5" | Match each `<epic>.<story>` to canonical keys (`3-1-*`, `3-2-*`, `4-5-*`) in sprint-status order; `autopilot start --stories <csv>` |
59
+ | "4-8-realm-wide-matcher" (already canonical) | `autopilot start --stories 4-8-realm-wide-matcher` |
60
+ | "voice identity", "matcher" (name fragment) | Search story slugs in sprint-status for fuzzy matches |
61
+ | "starting from 4.5" (everything from here) | Resolve `4.5` to canonical key, then queue it + every subsequent non-done story in sprint-status order: `autopilot start --stories <key1>,<key2>,...` |
62
+ | (no extra args) | Plain `autopilot start` — orchestrator picks the next pending story |
63
+
64
+ 3. **Ambiguity handling.** If a name fragment or number matches more
65
+ than one story, **do not pick arbitrarily**. List the candidates and
66
+ ask the user to disambiguate. Example:
67
+
68
+ > "voice identity" matches 3 stories:
69
+ > - 3-2-speaker-enrollment
70
+ > - 4-2b-voice-identity-matcher
71
+ > - 4-8-realm-wide-matcher-and-session-lock
72
+ > Which one(s) do you mean?
73
+
74
+ Do not invoke `autopilot start` with a guess.
75
+
76
+ 4. **Validation.** Every resolved key must exist in sprint-status.yaml
77
+ and not be `done`. The orchestrator double-checks this and errors
78
+ out otherwise — but verifying ahead of time gives the user clearer
79
+ feedback. If a key resolves to a `done` entry, mention that and ask
80
+ whether they meant something else.
81
+
82
+ 5. **Mid-sprint overwrite.** If `autopilot state` shows a sprint already
83
+ in progress (`current_story` is set or `story_queue` is non-empty)
84
+ AND the user is asking to start something different, the orchestrator
85
+ will refuse without `--force`. Confirm with the user before adding
86
+ `--force` — it discards the current story identity.
87
+
88
+ 6. **Continuation behavior.** Once the explicit queue exhausts, the
89
+ orchestrator falls back to its normal next-pending-story resolver
90
+ (so a user who says "epic 4" gets epic 4 done, then continues with
91
+ whatever comes next in sprint-status — including epic 5+). Tell the
92
+ user this if they ask.
93
+
94
+ 7. **Then proceed normally.** After `autopilot start ...` returns
95
+ successfully, follow `workflow.orchestrator.md` from `autopilot next`
96
+ onward.
97
+
98
+ ### Examples
99
+
100
+ | Input | Resolved invocation |
101
+ |---|---|
102
+ | `/sprint-autopilot-on` | `autopilot start --project-root <root>` |
103
+ | `/sprint-autopilot-on epic 4` | `autopilot start --epic 4 --project-root <root>` |
104
+ | `/sprint-autopilot-on stories 3.1, 3.2` (after matching `3-1-game-engine`, `3-2-input-parser`) | `autopilot start --stories 3-1-game-engine,3-2-input-parser --project-root <root>` |
105
+ | `/sprint-autopilot-on 4-8-realm-wide-matcher-and-session-lock` | `autopilot start --stories 4-8-realm-wide-matcher-and-session-lock --project-root <root>` |
106
+ | `/sprint-autopilot-on starting from 4.5` (resolved + all-subsequent) | `autopilot start --stories 4-5-realm-config,4-8-realm-wide-matcher --project-root <root>` |
107
+
108
+ Failure cases that should stop you (do NOT invoke autopilot):
109
+
110
+ - `sprint-status.yaml` missing → ask the user to run sprint-planning.
111
+ - Ambiguous match → list candidates, ask which.
112
+ - Every resolved key is `done` → tell the user there's nothing to run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
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": {