@ikunin/sprintpilot 2.1.3 → 2.1.5
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,86 @@ 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
|
+
// non-done STORY key (filtering out epic rollup headers), 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
|
+
// - the only non-done entries are epic rollups (no real stories yet)
|
|
117
|
+
function resolveNextStoryKey(projectRoot) {
|
|
118
|
+
if (!projectRoot) return null;
|
|
119
|
+
const sprintStatusPath = path.join(
|
|
120
|
+
projectRoot,
|
|
121
|
+
'_bmad-output',
|
|
122
|
+
'implementation-artifacts',
|
|
123
|
+
'sprint-status.yaml',
|
|
124
|
+
);
|
|
125
|
+
if (!safeExistsSync(sprintStatusPath)) return null;
|
|
126
|
+
try {
|
|
127
|
+
const raw = fs.readFileSync(sprintStatusPath, 'utf8');
|
|
128
|
+
const stories = parseSprintStatuses(raw);
|
|
129
|
+
const remaining = remainingStoriesFrom(stories);
|
|
130
|
+
// parseStatuses returns every key under `development_status:` —
|
|
131
|
+
// including BMad's epic rollup headers (`epic-4: in-progress`).
|
|
132
|
+
// Filter them out so we don't ask the orchestrator to branch on an
|
|
133
|
+
// epic identifier (the v2.1.4 hotfix shipped without this filter and
|
|
134
|
+
// a user reported branch: story/epic-4 instead of story/4-8-...).
|
|
135
|
+
const realStories = remaining.filter(looksLikeStoryKey);
|
|
136
|
+
return realStories.length > 0 ? realStories[0] : null;
|
|
137
|
+
} catch (_e) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Tell story keys apart from non-story bookkeeping entries in
|
|
143
|
+
// sprint-status.yaml. BMad development_status: holds three kinds of
|
|
144
|
+
// entries that parseStatuses returns side-by-side:
|
|
145
|
+
//
|
|
146
|
+
// 1. Real stories — `4-8-realm-wide-matcher` / `epic-1-game-engine`.
|
|
147
|
+
// Always have at least one hyphen AFTER the epic identifier.
|
|
148
|
+
// 2. Epic rollup headers — `epic-4` / bare `4`. The status reflects
|
|
149
|
+
// child-story rollup, not a unit of work for the autopilot.
|
|
150
|
+
// 3. Retrospective entries — `4-retrospective` / `epic-4-retrospective`.
|
|
151
|
+
// Status tracks whether the per-epic retro ritual has run; not a
|
|
152
|
+
// story to dev.
|
|
153
|
+
//
|
|
154
|
+
// Reject (2) and (3). The v2.1.4 hotfix shipped without this filter and
|
|
155
|
+
// the user reported `branch: story/epic-4` instead of the real next
|
|
156
|
+
// pending story. The v2.1.5 hotfix extends the filter to retrospectives
|
|
157
|
+
// after a follow-up report.
|
|
158
|
+
function looksLikeStoryKey(key) {
|
|
159
|
+
if (typeof key !== 'string' || !key) return false;
|
|
160
|
+
// Retrospective entries (`-retrospective` suffix, with or without epic
|
|
161
|
+
// prefix). Match anywhere the suffix appears so `epic-4-retrospective`
|
|
162
|
+
// and `4-retrospective` are both rejected.
|
|
163
|
+
if (/-retrospective$/i.test(key)) return false;
|
|
164
|
+
// Strip any leading `epic-` prefix and require a remaining hyphen.
|
|
165
|
+
// `epic-4` → `4` → no hyphen → epic header (reject).
|
|
166
|
+
// `epic-1-game-engine` → `1-game-engine` → has hyphen → story (accept).
|
|
167
|
+
// `4-8-realm-wide-matcher` → unchanged → has hyphen → story (accept).
|
|
168
|
+
const withoutEpicPrefix = key.replace(/^epic-/i, '');
|
|
169
|
+
return withoutEpicPrefix.includes('-');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Derive the epic identifier from a BMad story key. Convention:
|
|
173
|
+
// `epic-N-slug` → `epic-N`; `<epic>-<story>-<slug>` → `<epic>`.
|
|
174
|
+
// Returns null when the key doesn't parse cleanly. Kept in sync with
|
|
175
|
+
// adapt.js#deriveEpicKey; centralized here so composeRuntimeState
|
|
176
|
+
// doesn't have to import adapt's private helper.
|
|
177
|
+
function deriveEpicFromStoryKey(storyKey) {
|
|
178
|
+
if (typeof storyKey !== 'string' || !storyKey) return null;
|
|
179
|
+
const epicPrefixed = storyKey.match(/^(epic-[A-Za-z0-9_]+)-/);
|
|
180
|
+
if (epicPrefixed) return epicPrefixed[1];
|
|
181
|
+
const firstSeg = storyKey.match(/^([A-Za-z0-9_]+)-/);
|
|
182
|
+
if (firstSeg) return firstSeg[1];
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
102
186
|
function persistState(updates, profile, projectRoot, story) {
|
|
103
187
|
return stateStore.write(updates, profile, { projectRoot, story });
|
|
104
188
|
}
|
|
@@ -114,7 +198,7 @@ function persistState(updates, profile, projectRoot, story) {
|
|
|
114
198
|
// regardless of which CLI entrypoint composed the runtime (workflow.
|
|
115
199
|
// orchestrator.md tells the LLM to call `next` directly, bypassing
|
|
116
200
|
// cmdStart).
|
|
117
|
-
function composeRuntimeState(persisted, profile) {
|
|
201
|
+
function composeRuntimeState(persisted, profile, projectRoot) {
|
|
118
202
|
// Fresh-sprint initial phase. When git settings require a per-story or
|
|
119
203
|
// per-epic branch (granularity ∈ {story, epic} AND !reuse_user_branch
|
|
120
204
|
// AND git.enabled !== false), boot at PREPARE_STORY_BRANCH so the very
|
|
@@ -180,11 +264,45 @@ function composeRuntimeState(persisted, profile) {
|
|
|
180
264
|
) {
|
|
181
265
|
phase = STATES.PREPARE_STORY_BRANCH;
|
|
182
266
|
}
|
|
267
|
+
|
|
268
|
+
// Resolve story_key for PREPARE_STORY_BRANCH. The branch creation step
|
|
269
|
+
// needs a known story_key (and current_epic under granularity=epic) to
|
|
270
|
+
// compute the branch name — without resolution, branchName() falls
|
|
271
|
+
// back to "story/unknown" and we'd push a useless ref to origin.
|
|
272
|
+
//
|
|
273
|
+
// Why this is needed: PREPARE_STORY_BRANCH runs BEFORE CREATE_STORY in
|
|
274
|
+
// the full flow (and NANO_QUICK_DEV picks the story itself in quick
|
|
275
|
+
// flow), so the story_key from persisted state can be null on a fresh
|
|
276
|
+
// sprint. Read sprint-status.yaml — the same source of truth bmad-
|
|
277
|
+
// create-story / bmad-quick-dev use — to look ahead and find the
|
|
278
|
+
// next pending story.
|
|
279
|
+
//
|
|
280
|
+
// If nothing is pending (pre-planning OR sprint complete OR parse
|
|
281
|
+
// failure), fall back to flowStart so the LLM gets a meaningful
|
|
282
|
+
// skill invocation. PREPARE_STORY_BRANCH with no story_key would be
|
|
283
|
+
// a confusing emission to act on.
|
|
284
|
+
let resolvedStoryKey = persisted.current_story || null;
|
|
285
|
+
let resolvedEpic = persisted.current_epic || null;
|
|
286
|
+
let resolvedStoryFilePath = persisted.story_file_path || null;
|
|
287
|
+
if (phase === STATES.PREPARE_STORY_BRANCH && !resolvedStoryKey) {
|
|
288
|
+
const next = resolveNextStoryKey(projectRoot);
|
|
289
|
+
if (next) {
|
|
290
|
+
resolvedStoryKey = next;
|
|
291
|
+
if (!resolvedEpic) resolvedEpic = deriveEpicFromStoryKey(next);
|
|
292
|
+
} else {
|
|
293
|
+
process.stderr.write(
|
|
294
|
+
`[autopilot] WARN PREPARE_STORY_BRANCH needs a next-story key but sprint-status.yaml has none pending — falling back to ${flowStart}. ` +
|
|
295
|
+
'Run BMad sprint-planning first, or set git.reuse_user_branch=true to commit on the current branch.\n',
|
|
296
|
+
);
|
|
297
|
+
phase = flowStart;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
183
301
|
return {
|
|
184
302
|
phase,
|
|
185
|
-
story_key:
|
|
186
|
-
story_file_path:
|
|
187
|
-
current_epic:
|
|
303
|
+
story_key: resolvedStoryKey,
|
|
304
|
+
story_file_path: resolvedStoryFilePath,
|
|
305
|
+
current_epic: resolvedEpic,
|
|
188
306
|
ac_summary: persisted.ac_summary || null,
|
|
189
307
|
prior_diagnosis: persisted.prior_diagnosis || null,
|
|
190
308
|
relevant_decisions: persisted.relevant_decisions || [],
|
|
@@ -554,7 +672,7 @@ function cmdStart(opts) {
|
|
|
554
672
|
|
|
555
673
|
// Fresh start or clean resume. `composeRuntimeState` applies the
|
|
556
674
|
// profile-aware initial phase when persisted state is empty.
|
|
557
|
-
const runtime = composeRuntimeState(persisted, profile);
|
|
675
|
+
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
558
676
|
|
|
559
677
|
const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
|
|
560
678
|
if (lockResult && lockResult.halt) {
|
|
@@ -580,7 +698,7 @@ function cmdNext(opts) {
|
|
|
580
698
|
const projectRoot = resolveProjectRoot(opts);
|
|
581
699
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
582
700
|
const persisted = loadState(projectRoot);
|
|
583
|
-
const runtime = composeRuntimeState(persisted, profile);
|
|
701
|
+
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
584
702
|
|
|
585
703
|
// The LLM-driven workflow (workflow.orchestrator.md) tells the LLM to
|
|
586
704
|
// call `next` directly without `start` — apply the same branch-reuse
|
|
@@ -618,7 +736,7 @@ function cmdRecord(opts) {
|
|
|
618
736
|
const projectRoot = resolveProjectRoot(opts);
|
|
619
737
|
const { typed: profile } = resolveProfile(projectRoot, opts.profile);
|
|
620
738
|
const persisted = loadState(projectRoot);
|
|
621
|
-
const runtime = composeRuntimeState(persisted, profile);
|
|
739
|
+
const runtime = composeRuntimeState(persisted, profile, projectRoot);
|
|
622
740
|
|
|
623
741
|
let signalJson;
|
|
624
742
|
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: { ...
|
|
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',
|
package/package.json
CHANGED