@ikunin/sprintpilot 2.1.3 → 2.1.4

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.
@@ -39,6 +39,10 @@ const userCommands = require('../lib/orchestrator/user-commands');
39
39
  const divergence = require('../lib/orchestrator/divergence');
40
40
  const reportRenderer = require('../lib/orchestrator/report');
41
41
  const gitPlan = require('../lib/orchestrator/git-plan');
42
+ const {
43
+ parseStatuses: parseSprintStatuses,
44
+ remainingFrom: remainingStoriesFrom,
45
+ } = require('../scripts/list-remaining-stories');
42
46
 
43
47
  const { STATES } = stateMachine;
44
48
 
@@ -99,6 +103,49 @@ function safeExistsSync(p) {
99
103
  }
100
104
  }
101
105
 
106
+ // Resolve the next pending story key from BMad's sprint-status.yaml.
107
+ // Used by composeRuntimeState to populate state.story_key BEFORE
108
+ // emitting PREPARE_STORY_BRANCH — without this, branchName() falls
109
+ // back to "story/unknown" because state.story_key is null on a fresh
110
+ // sprint (CREATE_STORY hasn't run yet; for nano there's no CREATE_STORY
111
+ // at all and quick-dev reads sprint-status itself). Returns the first
112
+ // story whose status is NOT "done" (case-insensitive), or null when:
113
+ // - the status file doesn't exist (pre-planning)
114
+ // - all stories are done (sprint complete)
115
+ // - the file can't be parsed
116
+ function resolveNextStoryKey(projectRoot) {
117
+ if (!projectRoot) return null;
118
+ const sprintStatusPath = path.join(
119
+ projectRoot,
120
+ '_bmad-output',
121
+ 'implementation-artifacts',
122
+ 'sprint-status.yaml',
123
+ );
124
+ if (!safeExistsSync(sprintStatusPath)) return null;
125
+ try {
126
+ const raw = fs.readFileSync(sprintStatusPath, 'utf8');
127
+ const stories = parseSprintStatuses(raw);
128
+ const remaining = remainingStoriesFrom(stories);
129
+ return remaining.length > 0 ? remaining[0] : null;
130
+ } catch (_e) {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ // Derive the epic identifier from a BMad story key. Convention:
136
+ // `epic-N-slug` → `epic-N`; `<epic>-<story>-<slug>` → `<epic>`.
137
+ // Returns null when the key doesn't parse cleanly. Kept in sync with
138
+ // adapt.js#deriveEpicKey; centralized here so composeRuntimeState
139
+ // doesn't have to import adapt's private helper.
140
+ function deriveEpicFromStoryKey(storyKey) {
141
+ if (typeof storyKey !== 'string' || !storyKey) return null;
142
+ const epicPrefixed = storyKey.match(/^(epic-[A-Za-z0-9_]+)-/);
143
+ if (epicPrefixed) return epicPrefixed[1];
144
+ const firstSeg = storyKey.match(/^([A-Za-z0-9_]+)-/);
145
+ if (firstSeg) return firstSeg[1];
146
+ return null;
147
+ }
148
+
102
149
  function persistState(updates, profile, projectRoot, story) {
103
150
  return stateStore.write(updates, profile, { projectRoot, story });
104
151
  }
@@ -114,7 +161,7 @@ function persistState(updates, profile, projectRoot, story) {
114
161
  // regardless of which CLI entrypoint composed the runtime (workflow.
115
162
  // orchestrator.md tells the LLM to call `next` directly, bypassing
116
163
  // cmdStart).
117
- function composeRuntimeState(persisted, profile) {
164
+ function composeRuntimeState(persisted, profile, projectRoot) {
118
165
  // Fresh-sprint initial phase. When git settings require a per-story or
119
166
  // per-epic branch (granularity ∈ {story, epic} AND !reuse_user_branch
120
167
  // AND git.enabled !== false), boot at PREPARE_STORY_BRANCH so the very
@@ -180,11 +227,45 @@ function composeRuntimeState(persisted, profile) {
180
227
  ) {
181
228
  phase = STATES.PREPARE_STORY_BRANCH;
182
229
  }
230
+
231
+ // Resolve story_key for PREPARE_STORY_BRANCH. The branch creation step
232
+ // needs a known story_key (and current_epic under granularity=epic) to
233
+ // compute the branch name — without resolution, branchName() falls
234
+ // back to "story/unknown" and we'd push a useless ref to origin.
235
+ //
236
+ // Why this is needed: PREPARE_STORY_BRANCH runs BEFORE CREATE_STORY in
237
+ // the full flow (and NANO_QUICK_DEV picks the story itself in quick
238
+ // flow), so the story_key from persisted state can be null on a fresh
239
+ // sprint. Read sprint-status.yaml — the same source of truth bmad-
240
+ // create-story / bmad-quick-dev use — to look ahead and find the
241
+ // next pending story.
242
+ //
243
+ // If nothing is pending (pre-planning OR sprint complete OR parse
244
+ // failure), fall back to flowStart so the LLM gets a meaningful
245
+ // skill invocation. PREPARE_STORY_BRANCH with no story_key would be
246
+ // a confusing emission to act on.
247
+ let resolvedStoryKey = persisted.current_story || null;
248
+ let resolvedEpic = persisted.current_epic || null;
249
+ let resolvedStoryFilePath = persisted.story_file_path || null;
250
+ if (phase === STATES.PREPARE_STORY_BRANCH && !resolvedStoryKey) {
251
+ const next = resolveNextStoryKey(projectRoot);
252
+ if (next) {
253
+ resolvedStoryKey = next;
254
+ if (!resolvedEpic) resolvedEpic = deriveEpicFromStoryKey(next);
255
+ } else {
256
+ process.stderr.write(
257
+ `[autopilot] WARN PREPARE_STORY_BRANCH needs a next-story key but sprint-status.yaml has none pending — falling back to ${flowStart}. ` +
258
+ 'Run BMad sprint-planning first, or set git.reuse_user_branch=true to commit on the current branch.\n',
259
+ );
260
+ phase = flowStart;
261
+ }
262
+ }
263
+
183
264
  return {
184
265
  phase,
185
- story_key: persisted.current_story || null,
186
- story_file_path: persisted.story_file_path || null,
187
- current_epic: persisted.current_epic || null,
266
+ story_key: resolvedStoryKey,
267
+ story_file_path: resolvedStoryFilePath,
268
+ current_epic: resolvedEpic,
188
269
  ac_summary: persisted.ac_summary || null,
189
270
  prior_diagnosis: persisted.prior_diagnosis || null,
190
271
  relevant_decisions: persisted.relevant_decisions || [],
@@ -554,7 +635,7 @@ function cmdStart(opts) {
554
635
 
555
636
  // Fresh start or clean resume. `composeRuntimeState` applies the
556
637
  // profile-aware initial phase when persisted state is empty.
557
- const runtime = composeRuntimeState(persisted, profile);
638
+ const runtime = composeRuntimeState(persisted, profile, projectRoot);
558
639
 
559
640
  const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
560
641
  if (lockResult && lockResult.halt) {
@@ -580,7 +661,7 @@ function cmdNext(opts) {
580
661
  const projectRoot = resolveProjectRoot(opts);
581
662
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
582
663
  const persisted = loadState(projectRoot);
583
- const runtime = composeRuntimeState(persisted, profile);
664
+ const runtime = composeRuntimeState(persisted, profile, projectRoot);
584
665
 
585
666
  // The LLM-driven workflow (workflow.orchestrator.md) tells the LLM to
586
667
  // call `next` directly without `start` — apply the same branch-reuse
@@ -618,7 +699,7 @@ function cmdRecord(opts) {
618
699
  const projectRoot = resolveProjectRoot(opts);
619
700
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
620
701
  const persisted = loadState(projectRoot);
621
- const runtime = composeRuntimeState(persisted, profile);
702
+ const runtime = composeRuntimeState(persisted, profile, projectRoot);
622
703
 
623
704
  let signalJson;
624
705
  if (opts['signal-file']) {
@@ -397,12 +397,31 @@ function handleUserInput(state, signal, profile, sideEffects) {
397
397
 
398
398
  // One-shot dispatch (e.g. accept_alternative resolved a pending alt)?
399
399
  // Return the dispatched action in place of the state-machine's default.
400
+ //
401
+ // Sync story metadata onto newState. The dispatched action carries
402
+ // story_key / current_epic / story_file_path / ac_summary in its
403
+ // `template_slots` (and sometimes as top-level fields on git_ops).
404
+ // Without propagating these, accept_alternative dispatches work on
405
+ // a specific story but autopilot-state.yaml still shows
406
+ // `current_story: null` — subsequent emissions / persists / verify
407
+ // checks all reference the wrong story.
400
408
  const dispatch = applied.sideEffects.find((e) => e && e.kind === 'dispatch_action');
401
409
  if (dispatch && dispatch.action) {
410
+ const a = dispatch.action;
411
+ const slots = a.template_slots || {};
412
+ const enrichedState = {
413
+ ...newState,
414
+ story_key: newState.story_key || slots.story_key || a.story_key || null,
415
+ current_epic:
416
+ newState.current_epic || slots.current_epic || a.epic_key || null,
417
+ story_file_path:
418
+ newState.story_file_path || slots.story_file_path || null,
419
+ ac_summary: newState.ac_summary || slots.ac_summary || null,
420
+ };
402
421
  return {
403
- newState,
422
+ newState: enrichedState,
404
423
  newProfile,
405
- nextAction: { ...dispatch.action, _dispatched_via: dispatch.reason || 'user_input' },
424
+ nextAction: { ...a, _dispatched_via: dispatch.reason || 'user_input' },
406
425
  sideEffects,
407
426
  verdict: 'advanced',
408
427
  };
@@ -172,7 +172,30 @@ function nextAction(state, profile) {
172
172
  }
173
173
 
174
174
  switch (state.phase) {
175
- case STATES.PREPARE_STORY_BRANCH:
175
+ case STATES.PREPARE_STORY_BRANCH: {
176
+ // Safety net: PREPARE_STORY_BRANCH needs a known story_key (and,
177
+ // under granularity=epic, a current_epic) so git-plan.branchName
178
+ // can compute a real branch. composeRuntimeState resolves these
179
+ // from sprint-status.yaml before we get here — but if BOTH are
180
+ // null (sprint-status was empty, unreadable, or doesn't exist
181
+ // yet) we'd emit `branch: story/unknown` and confuse the runner.
182
+ // Emit a user_prompt instead so the user fixes the upstream
183
+ // condition (run BMad sprint-planning) rather than acting on a
184
+ // garbage action.
185
+ const haveStoryKey = !!state.story_key;
186
+ const haveEpicForBranch =
187
+ profile.granularity === 'epic' && !!state.current_epic;
188
+ if (!haveStoryKey && !haveEpicForBranch) {
189
+ return {
190
+ type: 'user_prompt',
191
+ phase: state.phase,
192
+ reason: 'prepare_story_branch_no_story_key',
193
+ prompt:
194
+ 'PREPARE_STORY_BRANCH was emitted but the orchestrator could not resolve a next story_key from sprint-status.yaml. ' +
195
+ 'Either run BMad sprint-planning to populate sprint-status.yaml, set `git.reuse_user_branch: true` in modules/git/config.yaml to commit on the current branch, ' +
196
+ 'or set `git.enabled: false` for a dry run without git operations.',
197
+ };
198
+ }
176
199
  // The edge layer (autopilot.js#decorateGitOp) inlines the planned
177
200
  // argv steps via git-plan.js#planCreateBranch. It also probes git
178
201
  // for branch existence and threads `state.branch_exists` through
@@ -187,6 +210,7 @@ function nextAction(state, profile) {
187
210
  epic_key: state.current_epic,
188
211
  profile: profile.name,
189
212
  };
213
+ }
190
214
  case STATES.CREATE_STORY:
191
215
  return {
192
216
  type: 'invoke_skill',
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.1.3
3
+ version: 2.1.4
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
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": {