@ikunin/sprintpilot 2.2.30 → 2.3.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 +232 -413
- package/_Sprintpilot/Sprintpilot.md +76 -6
- package/_Sprintpilot/bin/autopilot.js +752 -66
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
- package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +107 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +124 -1
- package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
- package/_Sprintpilot/manifest.yaml +4 -1
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
- package/_Sprintpilot/modules/git/config.yaml +15 -9
- package/_Sprintpilot/modules/ma/config.yaml +29 -27
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
- package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
- package/_Sprintpilot/scripts/log-timing.js +6 -10
- package/_Sprintpilot/scripts/merge-shards.js +21 -23
- package/_Sprintpilot/scripts/post-green-gates.js +3 -1
- package/_Sprintpilot/scripts/resolve-dag.js +452 -280
- package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
- package/_Sprintpilot/scripts/state-shard.js +13 -5
- package/_Sprintpilot/scripts/summarize-timings.js +2 -3
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
- package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
- package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
- package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
- package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
- package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
- package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
- package/lib/commands/install.js +186 -10
- package/package.json +1 -1
|
@@ -40,6 +40,8 @@ const divergence = require('../lib/orchestrator/divergence');
|
|
|
40
40
|
const reportRenderer = require('../lib/orchestrator/report');
|
|
41
41
|
const gitPlan = require('../lib/orchestrator/git-plan');
|
|
42
42
|
const land = require('../lib/orchestrator/land');
|
|
43
|
+
const orchSprintPlan = require('../lib/orchestrator/sprint-plan');
|
|
44
|
+
const sprintPlanScript = require('../scripts/sprint-plan');
|
|
43
45
|
const {
|
|
44
46
|
parseStatuses: parseSprintStatuses,
|
|
45
47
|
remainingFrom: remainingStoriesFrom,
|
|
@@ -47,7 +49,7 @@ const {
|
|
|
47
49
|
|
|
48
50
|
const { STATES } = stateMachine;
|
|
49
51
|
|
|
50
|
-
const SUBCOMMANDS = ['start', 'next', 'record', 'state', 'report', 'validate-config', 'status'];
|
|
52
|
+
const SUBCOMMANDS = ['start', 'next', 'record', 'state', 'report', 'validate-config', 'status', 'progress'];
|
|
51
53
|
|
|
52
54
|
function help() {
|
|
53
55
|
log.out(
|
|
@@ -149,9 +151,9 @@ function resolveNextStoryKey(projectRoot) {
|
|
|
149
151
|
const remaining = remainingStoriesFrom(stories);
|
|
150
152
|
// parseStatuses returns every key under `development_status:` —
|
|
151
153
|
// including BMad's epic rollup headers (`epic-4: in-progress`).
|
|
152
|
-
// Filter them out so
|
|
153
|
-
//
|
|
154
|
-
//
|
|
154
|
+
// Filter them out so the orchestrator never branches on an epic
|
|
155
|
+
// identifier (which would produce `story/epic-4` instead of
|
|
156
|
+
// `story/4-8-...`).
|
|
155
157
|
const realStories = remaining.filter(looksLikeStoryKey);
|
|
156
158
|
return realStories.length > 0 ? realStories[0] : null;
|
|
157
159
|
} catch (_e) {
|
|
@@ -165,7 +167,7 @@ function resolveNextStoryKey(projectRoot) {
|
|
|
165
167
|
// orchestrator should drop it and re-resolve).
|
|
166
168
|
//
|
|
167
169
|
// NARROW filter (vs looksLikeStoryKey which is strict). Only rejects:
|
|
168
|
-
// - `epic-N` shape (epic-rollup header
|
|
170
|
+
// - `epic-N` shape (epic-rollup header — not a story id)
|
|
169
171
|
// - bare numeric `N` (legacy bare-id epic form)
|
|
170
172
|
// - `*-retrospective` shape
|
|
171
173
|
// Accepts everything else as a plausible story key (including short test
|
|
@@ -179,7 +181,7 @@ function resolveNextStoryKey(projectRoot) {
|
|
|
179
181
|
function persistedStoryRejectionReason(key, projectRoot) {
|
|
180
182
|
if (typeof key !== 'string' || !key) return 'not a string';
|
|
181
183
|
if (isObviouslyEpicHeader(key)) {
|
|
182
|
-
return 'matches epic-rollup header shape (epic-N or bare N) —
|
|
184
|
+
return 'matches epic-rollup header shape (epic-N or bare N) — not a story id';
|
|
183
185
|
}
|
|
184
186
|
if (/-retrospective$/i.test(key)) {
|
|
185
187
|
return 'matches retrospective entry shape — not a story';
|
|
@@ -193,14 +195,91 @@ function persistedStoryRejectionReason(key, projectRoot) {
|
|
|
193
195
|
if (status === 'done') {
|
|
194
196
|
return `sprint-status shows status='done'; story already complete`;
|
|
195
197
|
}
|
|
198
|
+
// v2.3.0 — also reject when the user manually marked plan_status terminal
|
|
199
|
+
// in sprint-plan.yaml but sprint-status hasn't caught up. Returns null
|
|
200
|
+
// when no plan exists (greenfield projects keep existing semantics).
|
|
201
|
+
const planRejection = orchSprintPlan.planRejectionReason(key, { projectRoot });
|
|
202
|
+
if (planRejection) return planRejection;
|
|
196
203
|
return null;
|
|
197
204
|
}
|
|
198
205
|
|
|
199
|
-
//
|
|
200
|
-
//
|
|
206
|
+
// v2.3.0 Phase 4.5 — story-bound phases. When a transition involves any
|
|
207
|
+
// of these, emit story_step_started / story_step_completed ledger events
|
|
208
|
+
// so `autopilot progress` can render live sub-step status. NANO_QUICK_DEV
|
|
209
|
+
// is treated as a single sub-step (the inner Implement/Review/Classify/Commit
|
|
210
|
+
// loop happens inside bmad-quick-dev's own machinery).
|
|
211
|
+
function isStoryBoundPhase(phase) {
|
|
212
|
+
if (!phase || typeof phase !== 'string') return false;
|
|
213
|
+
return (
|
|
214
|
+
phase === STATES.CHECK_READINESS ||
|
|
215
|
+
phase === STATES.DEV_RED ||
|
|
216
|
+
phase === STATES.DEV_GREEN ||
|
|
217
|
+
phase === STATES.CODE_REVIEW ||
|
|
218
|
+
phase === STATES.PATCH_APPLY ||
|
|
219
|
+
phase === STATES.PATCH_RETEST ||
|
|
220
|
+
phase === STATES.STORY_DONE ||
|
|
221
|
+
phase === STATES.STORY_LAND ||
|
|
222
|
+
phase === STATES.NANO_QUICK_DEV
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Emit story_step_started + story_step_completed ledger events when the
|
|
227
|
+
// phase changes between two story-bound phases. Also writes the transient
|
|
228
|
+
// `current_step` field on the plan story entry so `autopilot progress` can
|
|
229
|
+
// render without re-reading the ledger. Best-effort + silent on failure;
|
|
230
|
+
// plan-layer issues never block the autopilot cycle.
|
|
231
|
+
function emitPhaseTransitionEvents(prevRuntime, newState, projectRoot) {
|
|
232
|
+
const prevPhase = prevRuntime && prevRuntime.phase;
|
|
233
|
+
const nextPhase = newState && newState.phase;
|
|
234
|
+
const story_key = newState && newState.story_key;
|
|
235
|
+
if (!nextPhase) return;
|
|
236
|
+
if (prevPhase === nextPhase) return;
|
|
237
|
+
|
|
238
|
+
const prevIsStoryBound = isStoryBoundPhase(prevPhase);
|
|
239
|
+
const nextIsStoryBound = isStoryBoundPhase(nextPhase);
|
|
240
|
+
if (!prevIsStoryBound && !nextIsStoryBound) return;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
if (prevIsStoryBound && story_key) {
|
|
244
|
+
ledger.append(
|
|
245
|
+
{
|
|
246
|
+
kind: 'story_step_completed',
|
|
247
|
+
detail: { story_key, step_name: prevPhase, outcome: 'success' },
|
|
248
|
+
},
|
|
249
|
+
{ projectRoot },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (nextIsStoryBound && story_key) {
|
|
253
|
+
ledger.append(
|
|
254
|
+
{
|
|
255
|
+
kind: 'story_step_started',
|
|
256
|
+
detail: { story_key, step_name: nextPhase, started_at: new Date().toISOString() },
|
|
257
|
+
},
|
|
258
|
+
{ projectRoot },
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// Ledger failures shouldn't ever wedge — skip silently.
|
|
263
|
+
log.warn(`phase transition ledger emission failed: ${e.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Mirror the phase into plan.stories[].current_step so the renderer
|
|
267
|
+
// doesn't need to re-tail the ledger to know what's running.
|
|
268
|
+
try {
|
|
269
|
+
const planRead = sprintPlanScript.read({ projectRoot });
|
|
270
|
+
if (planRead && !(typeof planRead === 'object' && 'error' in planRead) && story_key) {
|
|
271
|
+
const stepLabel = nextIsStoryBound ? nextPhase : null;
|
|
272
|
+
sprintPlanScript.markRunning(story_key, stepLabel, { projectRoot });
|
|
273
|
+
}
|
|
274
|
+
} catch (_e) {
|
|
275
|
+
// No plan or plan corrupt — fine; renderer will fall back to ledger.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Catch documented poisoned shapes that may appear in persisted.current_story
|
|
280
|
+
// (e.g. when sprint-status drift left a stale entry):
|
|
201
281
|
// - `epic-N` with no further hyphen-separated segments (epic rollup
|
|
202
|
-
// header
|
|
203
|
-
// first non-done entry before the looksLikeStoryKey filter shipped).
|
|
282
|
+
// header that should never have been written as a story id).
|
|
204
283
|
// - bare numeric `N` (legacy BMad bare-id epic form).
|
|
205
284
|
// Does NOT reject short test keys like `S1` / `S1.2` or other non-BMad
|
|
206
285
|
// naming conventions — those are valid persisted state.
|
|
@@ -223,10 +302,9 @@ function isObviouslyEpicHeader(key) {
|
|
|
223
302
|
// Status tracks whether the per-epic retro ritual has run; not a
|
|
224
303
|
// story to dev.
|
|
225
304
|
//
|
|
226
|
-
// Reject (2) and (3)
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// after a follow-up report.
|
|
305
|
+
// Reject (2) and (3) so the orchestrator never picks a rollup or
|
|
306
|
+
// retrospective entry as the next story (which would produce branches
|
|
307
|
+
// like `story/epic-4` or `story/4-retrospective`).
|
|
230
308
|
function looksLikeStoryKey(key) {
|
|
231
309
|
if (typeof key !== 'string' || !key) return false;
|
|
232
310
|
// Retrospective entries (`-retrospective` suffix, with or without epic
|
|
@@ -343,6 +421,25 @@ function readSprintStatuses(projectRoot) {
|
|
|
343
421
|
// - sprint-status doesn't exist or fails to parse
|
|
344
422
|
// - no stories match the epic
|
|
345
423
|
// - all matching stories are already done
|
|
424
|
+
// Terminal statuses for sprint-status entries — stories in these states
|
|
425
|
+
// are NOT counted as "remaining" for epic-done routing. BMad's official
|
|
426
|
+
// vocabulary only has `done`, but users frequently need to mark stories
|
|
427
|
+
// out-of-scope without lying that they shipped:
|
|
428
|
+
// - skipped / wont_do / cancelled / deferred — explicit user intent
|
|
429
|
+
// - abandoned — alternate spelling seen in the wild
|
|
430
|
+
// Any entry in TERMINAL_STATUSES is treated as non-remaining for
|
|
431
|
+
// epic-done routing.
|
|
432
|
+
const TERMINAL_STATUSES = new Set([
|
|
433
|
+
'done',
|
|
434
|
+
'skipped',
|
|
435
|
+
'wont_do',
|
|
436
|
+
"won't_do",
|
|
437
|
+
'cancelled',
|
|
438
|
+
'canceled',
|
|
439
|
+
'deferred',
|
|
440
|
+
'abandoned',
|
|
441
|
+
]);
|
|
442
|
+
|
|
346
443
|
function resolveStoriesForEpic(projectRoot, epicId) {
|
|
347
444
|
if (!epicId) return [];
|
|
348
445
|
const stories = readSprintStatuses(projectRoot);
|
|
@@ -354,7 +451,7 @@ function resolveStoriesForEpic(projectRoot, epicId) {
|
|
|
354
451
|
const derivedEpic = deriveEpicFromStoryKey(key);
|
|
355
452
|
if (derivedEpic !== epicId && derivedEpic !== `epic-${epicId}`) continue;
|
|
356
453
|
const status = String(stories[key].status || '').trim().toLowerCase();
|
|
357
|
-
if (status
|
|
454
|
+
if (TERMINAL_STATUSES.has(status)) continue;
|
|
358
455
|
out.push(key);
|
|
359
456
|
}
|
|
360
457
|
return out;
|
|
@@ -473,13 +570,8 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
473
570
|
// skill invocation. PREPARE_STORY_BRANCH with no story_key would be
|
|
474
571
|
// a confusing emission to act on.
|
|
475
572
|
// Validate persisted.current_story against sprint-status before
|
|
476
|
-
// trusting it.
|
|
477
|
-
//
|
|
478
|
-
// resolveNextStoryKey scanned sprint-status without filtering. The
|
|
479
|
-
// v2.1.5 hotfix added looksLikeStoryKey but only at resolution time —
|
|
480
|
-
// already-persisted poisoned values ride forward through every
|
|
481
|
-
// upgrade, producing emissions like `branch: story/epic-4` on every
|
|
482
|
-
// session boot.
|
|
573
|
+
// trusting it. Persisted state can drift from reality when stories
|
|
574
|
+
// get renamed, deleted, or merged externally between sessions.
|
|
483
575
|
//
|
|
484
576
|
// Treat persisted.current_story as null when:
|
|
485
577
|
// - it doesn't look like a real story key (epic header, retro, garbage)
|
|
@@ -504,9 +596,9 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
504
596
|
// a poisoned-state signal when state.phase is a story-bound phase
|
|
505
597
|
// (CHECK_READINESS through STORY_LAND) — at STORY_DONE the story
|
|
506
598
|
// IS expected to be marked done in sprint-status (verifyStoryDone
|
|
507
|
-
// enforces it).
|
|
508
|
-
// story_key mid-record
|
|
509
|
-
// commit_and_push_story.
|
|
599
|
+
// enforces it). Skipping the rejection at those phases avoids
|
|
600
|
+
// nulling story_key mid-record (which would produce branch
|
|
601
|
+
// "story/unknown" on commit_and_push_story).
|
|
510
602
|
//
|
|
511
603
|
// Epic-rollup-header / retrospective / not-in-sprint-status
|
|
512
604
|
// rejections are ALWAYS poison and fire regardless of phase.
|
|
@@ -526,8 +618,7 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
526
618
|
process.stderr.write(
|
|
527
619
|
`[autopilot] WARN persisted current_story "${persistedCurrentStory}" rejected: ${rejection}. ` +
|
|
528
620
|
'Treating as null and falling through to queue / sprint-status resolution. ' +
|
|
529
|
-
'
|
|
530
|
-
'next emission will clean it up.\n',
|
|
621
|
+
'Next emission will clean it up.\n',
|
|
531
622
|
);
|
|
532
623
|
resolvedStoryKey = null;
|
|
533
624
|
resolvedEpic = null;
|
|
@@ -616,17 +707,15 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
616
707
|
}
|
|
617
708
|
}
|
|
618
709
|
|
|
619
|
-
// Count non-
|
|
710
|
+
// Count non-terminal stories in the current epic. state-machine.js's
|
|
620
711
|
// EPIC_BOUNDARY_CHECK reads this to decide between RETROSPECTIVE (end
|
|
621
|
-
// of epic, count === 0) and next-story-start (count > 0).
|
|
622
|
-
//
|
|
623
|
-
// — so the count stayed at 0 and EVERY story triggered a
|
|
624
|
-
// retrospective. Now: recompute from sprint-status.yaml each emission
|
|
625
|
-
// when current_epic is known.
|
|
712
|
+
// of epic, count === 0) and next-story-start (count > 0). Recomputed
|
|
713
|
+
// from sprint-status.yaml each emission when current_epic is known.
|
|
626
714
|
//
|
|
627
|
-
// Count semantics: excludes
|
|
628
|
-
//
|
|
629
|
-
//
|
|
715
|
+
// Count semantics: excludes any TERMINAL_STATUSES entry (done,
|
|
716
|
+
// skipped, wont_do, cancelled, deferred, abandoned, …) AND non-story
|
|
717
|
+
// entries (epic rollup headers, -retrospective entries) via the same
|
|
718
|
+
// looksLikeStoryKey filter resolveNextStoryKey uses.
|
|
630
719
|
let remainingStoriesInEpic = persisted.remaining_stories_in_epic || 0;
|
|
631
720
|
if (resolvedEpic && projectRoot) {
|
|
632
721
|
const epicStories = resolveStoriesForEpic(projectRoot, resolvedEpic);
|
|
@@ -637,13 +726,11 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
637
726
|
// coherent action AND we still don't have one after every resolution
|
|
638
727
|
// path (queue / validator / sprint-status), reset phase to flowStart.
|
|
639
728
|
//
|
|
640
|
-
// Real-world scenario:
|
|
641
|
-
//
|
|
642
|
-
// reset
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
// guard catches that case + any future bug class where story_key
|
|
646
|
-
// ends up null at a story-bound phase.
|
|
729
|
+
// Real-world scenario: persisted state ends up with current_story: null
|
|
730
|
+
// at story_done (e.g. from manual edits or migration). The rejection-
|
|
731
|
+
// branch reset only fires when there's a rejection to fire; a NULL
|
|
732
|
+
// story_key doesn't trigger one. This guard catches that case + any
|
|
733
|
+
// bug class where story_key ends up null at a story-bound phase.
|
|
647
734
|
//
|
|
648
735
|
// The reset is safe: the next emission re-enters story-start (or
|
|
649
736
|
// PREPARE_STORY_BRANCH per the migration rule) and picks the next
|
|
@@ -858,13 +945,10 @@ function decorateGitOp(action, state, profile, projectRoot) {
|
|
|
858
945
|
}
|
|
859
946
|
}
|
|
860
947
|
|
|
861
|
-
// run_script actions for op=land_story carry only metadata
|
|
862
|
-
// land_when, squash_on_merge, ...)
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
// land_as_you_go got a metadata-only action and had to improvise.
|
|
866
|
-
// Symmetric to decorateGitOp: call land.planLand(state, profile,
|
|
867
|
-
// options), inline the resulting `steps[]` onto the action.
|
|
948
|
+
// run_script actions for op=land_story carry only metadata from the
|
|
949
|
+
// state machine (helper, land_when, squash_on_merge, ...). The CLI edge
|
|
950
|
+
// composes the actual argv via land.js#planLand and inlines it here —
|
|
951
|
+
// symmetric to decorateGitOp for git_op actions.
|
|
868
952
|
function decorateRunScript(action, state, profile, projectRoot) {
|
|
869
953
|
if (!action || action.type !== 'run_script') return action;
|
|
870
954
|
if (action.op === 'land_story') {
|
|
@@ -1051,7 +1135,19 @@ function probeBranchExists(projectRoot, branch) {
|
|
|
1051
1135
|
|
|
1052
1136
|
// ------------------------------------------------------------ side effects
|
|
1053
1137
|
|
|
1138
|
+
// v2.3.0 — applySideEffects MAY return a `surfaceFailure` envelope when
|
|
1139
|
+
// a plan_* side-effect cannot complete (DAG violation, missing keys,
|
|
1140
|
+
// disk error). cmdRecord uses this to override the emitted nextAction
|
|
1141
|
+
// with a user_prompt halt so the LLM session sees the failure rather
|
|
1142
|
+
// than silently moving on. Returns null when no failure needs surfacing.
|
|
1054
1143
|
function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
1144
|
+
let surfaceFailure = null;
|
|
1145
|
+
// Helper: record a plan-side-effect failure for the caller to surface.
|
|
1146
|
+
// First failure wins; subsequent failures are ledgered but not raised
|
|
1147
|
+
// (the user can only act on one prompt at a time).
|
|
1148
|
+
const recordFailure = (kind, prompt, details) => {
|
|
1149
|
+
if (!surfaceFailure) surfaceFailure = { kind, prompt, details };
|
|
1150
|
+
};
|
|
1055
1151
|
for (const eff of sideEffects || []) {
|
|
1056
1152
|
switch (eff.kind) {
|
|
1057
1153
|
case 'append_decisions': {
|
|
@@ -1116,11 +1212,130 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
|
1116
1212
|
ledger.append({ ...eff, kind }, { projectRoot });
|
|
1117
1213
|
break;
|
|
1118
1214
|
}
|
|
1215
|
+
case 'plan_reorder': {
|
|
1216
|
+
// v2.3.0 — DAG-validated reorder of plan.stories[]. Failures
|
|
1217
|
+
// emit a structured ledger entry AND surface a user_prompt halt
|
|
1218
|
+
// (via the surfaceFailure return) so the LLM session sees the
|
|
1219
|
+
// violation rather than silently moving on. Without this, the
|
|
1220
|
+
// user issues `reorder_queue` and gets no feedback when it fails.
|
|
1221
|
+
try {
|
|
1222
|
+
const planRead = sprintPlanScript.read({ projectRoot });
|
|
1223
|
+
if (!planRead || (typeof planRead === 'object' && 'error' in planRead)) {
|
|
1224
|
+
ledger.append(
|
|
1225
|
+
{ kind: 'plan_reorder_failed', reason: 'no_plan_or_corrupt' },
|
|
1226
|
+
{ projectRoot },
|
|
1227
|
+
);
|
|
1228
|
+
recordFailure(
|
|
1229
|
+
'plan_reorder_failed',
|
|
1230
|
+
`reorder_queue rejected: no sprint-plan.yaml exists yet or the file is corrupt. ` +
|
|
1231
|
+
`Run /sprintpilot-plan-sprint to build a plan first.`,
|
|
1232
|
+
{ reason: 'no_plan_or_corrupt' },
|
|
1233
|
+
);
|
|
1234
|
+
break;
|
|
1235
|
+
}
|
|
1236
|
+
const validation = orchSprintPlan.validateOrdering(eff.order, planRead, { projectRoot });
|
|
1237
|
+
if (!validation.valid) {
|
|
1238
|
+
ledger.append(
|
|
1239
|
+
{ kind: 'plan_reorder_rejected', violations: validation.violations },
|
|
1240
|
+
{ projectRoot },
|
|
1241
|
+
);
|
|
1242
|
+
const violationLines = validation.violations
|
|
1243
|
+
.slice(0, 5)
|
|
1244
|
+
.map((v) => ` - ${v.story} depends on ${v.upstream} (suggestion: ${v.suggestion})`)
|
|
1245
|
+
.join('\n');
|
|
1246
|
+
recordFailure(
|
|
1247
|
+
'plan_reorder_rejected',
|
|
1248
|
+
`reorder_queue violates the dependency DAG. Violations:\n${violationLines}` +
|
|
1249
|
+
(validation.violations.length > 5 ? `\n ...and ${validation.violations.length - 5} more` : '') +
|
|
1250
|
+
`\n\nResubmit reorder_queue with a corrected order, or use add_to_sprint to bring missing upstreams into the plan first.`,
|
|
1251
|
+
{ violations: validation.violations },
|
|
1252
|
+
);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
sprintPlanScript.reorder(eff.order, { projectRoot });
|
|
1256
|
+
ledger.append(
|
|
1257
|
+
{ kind: 'plan_reordered', order: eff.order, reason: eff.reason },
|
|
1258
|
+
{ projectRoot },
|
|
1259
|
+
);
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
ledger.append(
|
|
1262
|
+
{ kind: 'plan_reorder_failed', message: e.message },
|
|
1263
|
+
{ projectRoot },
|
|
1264
|
+
);
|
|
1265
|
+
recordFailure(
|
|
1266
|
+
'plan_reorder_failed',
|
|
1267
|
+
`reorder_queue failed: ${e.message}`,
|
|
1268
|
+
{ message: e.message },
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
case 'plan_add_stories': {
|
|
1274
|
+
try {
|
|
1275
|
+
// Build entries from story_keys; populate issue_id from optional map.
|
|
1276
|
+
const issueMap = eff.issue_ids && typeof eff.issue_ids === 'object' ? eff.issue_ids : {};
|
|
1277
|
+
const entries = eff.story_keys.map((key) => ({
|
|
1278
|
+
key,
|
|
1279
|
+
issue_id: typeof issueMap[key] === 'string' ? issueMap[key] : null,
|
|
1280
|
+
added_by: 'user',
|
|
1281
|
+
}));
|
|
1282
|
+
sprintPlanScript.addStories(entries, { projectRoot, position: eff.position || 'end' });
|
|
1283
|
+
ledger.append(
|
|
1284
|
+
{
|
|
1285
|
+
kind: 'plan_stories_added',
|
|
1286
|
+
story_keys: eff.story_keys,
|
|
1287
|
+
position: eff.position || 'end',
|
|
1288
|
+
reason: eff.reason,
|
|
1289
|
+
},
|
|
1290
|
+
{ projectRoot },
|
|
1291
|
+
);
|
|
1292
|
+
} catch (e) {
|
|
1293
|
+
ledger.append(
|
|
1294
|
+
{ kind: 'plan_add_stories_failed', message: e.message },
|
|
1295
|
+
{ projectRoot },
|
|
1296
|
+
);
|
|
1297
|
+
recordFailure(
|
|
1298
|
+
'plan_add_stories_failed',
|
|
1299
|
+
`add_to_sprint failed: ${e.message}`,
|
|
1300
|
+
{ message: e.message, story_keys: eff.story_keys },
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
case 'plan_remove_stories': {
|
|
1306
|
+
try {
|
|
1307
|
+
sprintPlanScript.removeStories(eff.story_keys, {
|
|
1308
|
+
projectRoot,
|
|
1309
|
+
status: eff.mark_status || 'skipped',
|
|
1310
|
+
});
|
|
1311
|
+
ledger.append(
|
|
1312
|
+
{
|
|
1313
|
+
kind: 'plan_stories_removed',
|
|
1314
|
+
story_keys: eff.story_keys,
|
|
1315
|
+
mark_status: eff.mark_status || 'skipped',
|
|
1316
|
+
reason: eff.reason,
|
|
1317
|
+
},
|
|
1318
|
+
{ projectRoot },
|
|
1319
|
+
);
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
ledger.append(
|
|
1322
|
+
{ kind: 'plan_remove_stories_failed', message: e.message },
|
|
1323
|
+
{ projectRoot },
|
|
1324
|
+
);
|
|
1325
|
+
recordFailure(
|
|
1326
|
+
'plan_remove_stories_failed',
|
|
1327
|
+
`remove_from_sprint failed: ${e.message}`,
|
|
1328
|
+
{ message: e.message, story_keys: eff.story_keys },
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1119
1333
|
default:
|
|
1120
1334
|
// Unknown side-effect kinds are recorded but otherwise ignored.
|
|
1121
1335
|
ledger.append({ kind: 'state_transition', detail: eff }, { projectRoot });
|
|
1122
1336
|
}
|
|
1123
1337
|
}
|
|
1338
|
+
return surfaceFailure;
|
|
1124
1339
|
}
|
|
1125
1340
|
|
|
1126
1341
|
// ------------------------------------------------------------ subcommands
|
|
@@ -1486,34 +1701,30 @@ function cmdStart(opts) {
|
|
|
1486
1701
|
);
|
|
1487
1702
|
}
|
|
1488
1703
|
|
|
1489
|
-
// parallel_stories: surface
|
|
1490
|
-
//
|
|
1491
|
-
// (planBatch, dispatch-layer.js, agent-adapter.js,
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
// time. A user who sets `ma.parallel_stories: true` and doesn't see
|
|
1495
|
-
// this notice would assume parallelism is happening when it isn't.
|
|
1704
|
+
// parallel_stories: when the flag is set, surface that the BMad state
|
|
1705
|
+
// machine emits stories sequentially even though the dispatch-layer
|
|
1706
|
+
// building blocks (planBatch, dispatch-layer.js, agent-adapter.js,
|
|
1707
|
+
// merge-shards.js) are wired. Without the notice users could assume
|
|
1708
|
+
// parallel emission is happening when it isn't.
|
|
1496
1709
|
if (profile.parallel_stories) {
|
|
1497
1710
|
ledger.append(
|
|
1498
1711
|
{
|
|
1499
1712
|
kind: 'state_transition',
|
|
1500
1713
|
detail: {
|
|
1501
|
-
|
|
1502
|
-
'ma.parallel_stories=true
|
|
1714
|
+
parallel_stories_notice:
|
|
1715
|
+
'ma.parallel_stories=true: the planBatch / dispatch-layer.js building blocks are honored, but the BMad state machine emits one story at a time in this build. Stories run sequentially.',
|
|
1503
1716
|
},
|
|
1504
1717
|
},
|
|
1505
1718
|
{ projectRoot },
|
|
1506
1719
|
);
|
|
1507
1720
|
process.stderr.write(
|
|
1508
|
-
'[autopilot]
|
|
1721
|
+
'[autopilot] NOTICE ma.parallel_stories=true honored at the dispatch-layer level; state-machine emission remains sequential in this build.\n',
|
|
1509
1722
|
);
|
|
1510
1723
|
}
|
|
1511
1724
|
if (profile.lint_enabled) {
|
|
1512
|
-
//
|
|
1725
|
+
// lint_enabled routes verifyDevGreen through post-green-gates.js
|
|
1513
1726
|
// (lint-changed + lint-test-pitfalls + ci-parity scan). lint_blocking
|
|
1514
|
-
// governs whether a failed gate
|
|
1515
|
-
// through with a warning. The v2.2.23 "not wired" warning is gone —
|
|
1516
|
-
// lint runs for real now.
|
|
1727
|
+
// governs whether a failed gate rejects verify or just records.
|
|
1517
1728
|
ledger.append(
|
|
1518
1729
|
{
|
|
1519
1730
|
kind: 'state_transition',
|
|
@@ -1560,6 +1771,175 @@ function cmdStart(opts) {
|
|
|
1560
1771
|
return 0;
|
|
1561
1772
|
}
|
|
1562
1773
|
|
|
1774
|
+
// v2.3.0 — plan-aware integration. Three independent steps, ordered for
|
|
1775
|
+
// simplicity:
|
|
1776
|
+
// 1. One-shot legacy import: if a pre-v2.3.0 `_Sprintpilot/sprints/dependencies.yaml`
|
|
1777
|
+
// exists, archive + import its content into sprint-plan.yaml.
|
|
1778
|
+
// 2. Refresh the plan's bmad_status cache from sprint-status.yaml.
|
|
1779
|
+
// Eagerly transitions terminal stories to plan_status=done so the
|
|
1780
|
+
// queue resolver doesn't pick them. No-op on a fresh plan or when
|
|
1781
|
+
// the diff is empty (Risk #23 disk-thrash mitigation).
|
|
1782
|
+
// 3. If no explicit --stories/--epic flags AND a plan with pending
|
|
1783
|
+
// stories exists, hydrate persisted.story_queue from the plan.
|
|
1784
|
+
// composeRuntimeState (below) consumes the queue head as usual —
|
|
1785
|
+
// no changes needed in composeRuntimeState itself.
|
|
1786
|
+
//
|
|
1787
|
+
// All three are best-effort. Failures emit a ledger event and fall
|
|
1788
|
+
// through to the legacy resolveNextStoryKey path; cmdStart never
|
|
1789
|
+
// wedges on plan-layer issues.
|
|
1790
|
+
try {
|
|
1791
|
+
const migration = orchSprintPlan.bootstrapMigrationIfNeeded({ projectRoot });
|
|
1792
|
+
if (migration && migration.migrated) {
|
|
1793
|
+
ledger.append({ kind: 'plan_migrated', detail: migration }, { projectRoot });
|
|
1794
|
+
} else if (migration && migration.reason === 'migrate_failed') {
|
|
1795
|
+
ledger.append({ kind: 'plan_migration_failed', detail: migration }, { projectRoot });
|
|
1796
|
+
}
|
|
1797
|
+
} catch (e) {
|
|
1798
|
+
ledger.append({ kind: 'plan_migration_failed', detail: { message: e.message } }, { projectRoot });
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
try {
|
|
1802
|
+
const refresh = orchSprintPlan.refreshIfPlanExists({ projectRoot });
|
|
1803
|
+
if (refresh && refresh.wrote) {
|
|
1804
|
+
ledger.append({ kind: 'plan_refreshed', detail: refresh.changed }, { projectRoot });
|
|
1805
|
+
}
|
|
1806
|
+
} catch (e) {
|
|
1807
|
+
ledger.append({ kind: 'plan_refresh_failed', detail: { message: e.message } }, { projectRoot });
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
if (explicitQueue.length === 0) {
|
|
1811
|
+
try {
|
|
1812
|
+
const planQueue = orchSprintPlan.composePlanQueue({ projectRoot });
|
|
1813
|
+
if (Array.isArray(planQueue) && planQueue.length > 0) {
|
|
1814
|
+
persisted.story_queue = planQueue;
|
|
1815
|
+
ledger.append(
|
|
1816
|
+
{ kind: 'plan_queue_loaded', queue: planQueue.slice(0, 20) },
|
|
1817
|
+
{ projectRoot },
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
} catch (e) {
|
|
1821
|
+
ledger.append(
|
|
1822
|
+
{ kind: 'plan_queue_failed', detail: { message: e.message } },
|
|
1823
|
+
{ projectRoot },
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Replan gate (v2.3.0) — user issued `replan_sprint` mid-flight; the
|
|
1829
|
+
// previous cmdRecord set state.replan_requested and halted. On the next
|
|
1830
|
+
// start, emit the invoke_skill action so the LLM session re-runs
|
|
1831
|
+
// /sprintpilot-plan-sprint. Clear the flag once emitted so the request
|
|
1832
|
+
// is one-shot.
|
|
1833
|
+
if (persisted.replan_requested) {
|
|
1834
|
+
const requested = persisted.replan_requested;
|
|
1835
|
+
const inviteAction = {
|
|
1836
|
+
type: 'invoke_skill',
|
|
1837
|
+
skill: 'sprintpilot-plan-sprint',
|
|
1838
|
+
template_slots: {
|
|
1839
|
+
replan: true,
|
|
1840
|
+
reason: requested.reason || 'user_requested',
|
|
1841
|
+
requested_at: requested.requested_at || null,
|
|
1842
|
+
},
|
|
1843
|
+
};
|
|
1844
|
+
persisted.replan_requested = null;
|
|
1845
|
+
persistState({ replan_requested: null }, profile, projectRoot, 'sprint');
|
|
1846
|
+
if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: 'sprint' });
|
|
1847
|
+
ledger.append(
|
|
1848
|
+
{ kind: 'replan_requested_consumed', detail: requested },
|
|
1849
|
+
{ projectRoot },
|
|
1850
|
+
);
|
|
1851
|
+
ledger.append(
|
|
1852
|
+
{ kind: 'action_emitted', phase: persisted.current_bmad_step || null, action: inviteAction },
|
|
1853
|
+
{ projectRoot },
|
|
1854
|
+
);
|
|
1855
|
+
process.stdout.write(
|
|
1856
|
+
`${JSON.stringify({ action: inviteAction, phase: persisted.current_bmad_step || null }, null, 2)}\n`,
|
|
1857
|
+
);
|
|
1858
|
+
return 0;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// Plan-exhaustion gate: every plan.stories[] entry is terminal AND the
|
|
1862
|
+
// plan was actually curated (stories list is non-empty). Archive the
|
|
1863
|
+
// plan and emit a halt asking the user to either re-plan or fall back
|
|
1864
|
+
// to sprint-status order. This is distinct from auto-derive: a fresh
|
|
1865
|
+
// plan that was just exhausted shouldn't silently slip into picking
|
|
1866
|
+
// up other sprint-status stories.
|
|
1867
|
+
if (explicitQueue.length === 0) {
|
|
1868
|
+
const exhausted = orchSprintPlan.planExhausted({ projectRoot });
|
|
1869
|
+
if (exhausted.exhausted) {
|
|
1870
|
+
let archived = null;
|
|
1871
|
+
try {
|
|
1872
|
+
const archiveResult = sprintPlanScript.archive(exhausted.plan_id, { projectRoot });
|
|
1873
|
+
archived = archiveResult.archived ? archiveResult.file : null;
|
|
1874
|
+
} catch (e) {
|
|
1875
|
+
ledger.append(
|
|
1876
|
+
{ kind: 'plan_archive_failed', detail: { message: e.message } },
|
|
1877
|
+
{ projectRoot },
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
const haltAction = {
|
|
1881
|
+
type: 'user_prompt',
|
|
1882
|
+
reason: 'plan_exhausted',
|
|
1883
|
+
prompt:
|
|
1884
|
+
`Sprint plan complete. All ${exhausted.total} planned stories are done ` +
|
|
1885
|
+
`(${exhausted.terminal_counts.done} done, ${exhausted.terminal_counts.skipped} skipped, ` +
|
|
1886
|
+
`${exhausted.terminal_counts.excluded} excluded). ` +
|
|
1887
|
+
'Run /sprintpilot-plan-sprint to build a new plan from remaining sprint-status stories, ' +
|
|
1888
|
+
'or run `autopilot start --no-auto-plan` to continue in sprint-status order.',
|
|
1889
|
+
plan_id: exhausted.plan_id,
|
|
1890
|
+
terminal_counts: exhausted.terminal_counts,
|
|
1891
|
+
archived,
|
|
1892
|
+
};
|
|
1893
|
+
ledger.append(
|
|
1894
|
+
{ kind: 'plan_exhausted', detail: { ...exhausted, archived } },
|
|
1895
|
+
{ projectRoot },
|
|
1896
|
+
);
|
|
1897
|
+
ledger.append(
|
|
1898
|
+
{ kind: 'action_emitted', phase: persisted.current_bmad_step || null, action: haltAction },
|
|
1899
|
+
{ projectRoot },
|
|
1900
|
+
);
|
|
1901
|
+
process.stdout.write(
|
|
1902
|
+
`${JSON.stringify({ action: haltAction, phase: persisted.current_bmad_step || null }, null, 2)}\n`,
|
|
1903
|
+
);
|
|
1904
|
+
return 0;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Auto-derive gate: emit an `invoke_skill` action that asks the LLM
|
|
1909
|
+
// session to run /sprintpilot-plan-sprint. Only fires when:
|
|
1910
|
+
// - the user opted in via `autopilot.auto_plan_on_start: true` (config), OR
|
|
1911
|
+
// - an existing plan went stale (added_stories / removed_stories).
|
|
1912
|
+
// Per user direction the default is OFF for greenfield projects —
|
|
1913
|
+
// missing plan falls back to sprint-status execution order.
|
|
1914
|
+
const autoDerive = orchSprintPlan.shouldAutoDerive({ projectRoot, profile, opts });
|
|
1915
|
+
if (autoDerive.auto_derive) {
|
|
1916
|
+
const inviteAction = {
|
|
1917
|
+
type: 'invoke_skill',
|
|
1918
|
+
skill: 'sprintpilot-plan-sprint',
|
|
1919
|
+
template_slots: {
|
|
1920
|
+
auto: true,
|
|
1921
|
+
reason: autoDerive.reason,
|
|
1922
|
+
...(autoDerive.missing_keys ? { missing_keys: autoDerive.missing_keys } : {}),
|
|
1923
|
+
...(autoDerive.removed_keys ? { removed_keys: autoDerive.removed_keys } : {}),
|
|
1924
|
+
},
|
|
1925
|
+
};
|
|
1926
|
+
ledger.append(
|
|
1927
|
+
{
|
|
1928
|
+
kind: 'auto_derive_emitted',
|
|
1929
|
+
detail: { reason: autoDerive.reason, ...autoDerive },
|
|
1930
|
+
},
|
|
1931
|
+
{ projectRoot },
|
|
1932
|
+
);
|
|
1933
|
+
ledger.append(
|
|
1934
|
+
{ kind: 'action_emitted', phase: persisted.current_bmad_step || null, action: inviteAction },
|
|
1935
|
+
{ projectRoot },
|
|
1936
|
+
);
|
|
1937
|
+
process.stdout.write(
|
|
1938
|
+
`${JSON.stringify({ action: inviteAction, phase: persisted.current_bmad_step || null }, null, 2)}\n`,
|
|
1939
|
+
);
|
|
1940
|
+
return 0;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1563
1943
|
// Persist the new queue BEFORE composing runtime state so the queue
|
|
1564
1944
|
// head is visible to composeRuntimeState's resolver.
|
|
1565
1945
|
if (explicitQueue.length > 0) {
|
|
@@ -1713,7 +2093,34 @@ function cmdRecord(opts) {
|
|
|
1713
2093
|
}
|
|
1714
2094
|
|
|
1715
2095
|
const result = adapt.interpretSignal(runtime, signal, profile, verifyResult);
|
|
1716
|
-
|
|
2096
|
+
const planFailure = applySideEffects(
|
|
2097
|
+
result.sideEffects,
|
|
2098
|
+
result.newState,
|
|
2099
|
+
result.newProfile,
|
|
2100
|
+
projectRoot,
|
|
2101
|
+
);
|
|
2102
|
+
// v2.3.0 — if a plan_* side-effect failed (DAG violation, validation
|
|
2103
|
+
// error, write failure), override the emitted nextAction with a
|
|
2104
|
+
// user_prompt halt so the LLM session sees the failure and can
|
|
2105
|
+
// remediate. Without this the autopilot silently moves on and the
|
|
2106
|
+
// user wonders why their reorder/add/remove "did nothing".
|
|
2107
|
+
if (planFailure) {
|
|
2108
|
+
const haltAction = {
|
|
2109
|
+
type: 'user_prompt',
|
|
2110
|
+
phase: result.newState.phase,
|
|
2111
|
+
reason: planFailure.kind,
|
|
2112
|
+
prompt: planFailure.prompt,
|
|
2113
|
+
details: planFailure.details || null,
|
|
2114
|
+
};
|
|
2115
|
+
ledger.append(
|
|
2116
|
+
{ kind: 'action_emitted', phase: result.newState.phase, action: haltAction },
|
|
2117
|
+
{ projectRoot },
|
|
2118
|
+
);
|
|
2119
|
+
process.stdout.write(
|
|
2120
|
+
`${JSON.stringify({ action: haltAction, phase: result.newState.phase }, null, 2)}\n`,
|
|
2121
|
+
);
|
|
2122
|
+
return 0;
|
|
2123
|
+
}
|
|
1717
2124
|
|
|
1718
2125
|
// Skill timing: emit `skill.<name>` end event when an invoke_skill phase
|
|
1719
2126
|
// advances to a new phase (success path) OR when it pauses with a
|
|
@@ -1742,6 +2149,46 @@ function cmdRecord(opts) {
|
|
|
1742
2149
|
// Persist new runtime state.
|
|
1743
2150
|
persistRuntimeState(result.newState, result.newProfile, projectRoot);
|
|
1744
2151
|
|
|
2152
|
+
// v2.3.0 Phase 4.5 — streaming progress. Emit step-level ledger events
|
|
2153
|
+
// on every phase transition so `autopilot progress` can render live
|
|
2154
|
+
// status. Mirrors the change to plan.stories[].current_step via
|
|
2155
|
+
// markRunning. Both are best-effort — plan-layer failures never wedge
|
|
2156
|
+
// cmdRecord. Only fires when the transition involves a story-bound
|
|
2157
|
+
// phase (skips sprint-level boundaries like SPRINT_FINALIZE_PENDING).
|
|
2158
|
+
emitPhaseTransitionEvents(runtime, result.newState, projectRoot);
|
|
2159
|
+
|
|
2160
|
+
// v2.3.0 — when a story transitions into STORY_DONE, sync the plan's
|
|
2161
|
+
// `plan_status` so the queue resolver drops the entry next cmdStart.
|
|
2162
|
+
// Best-effort + idempotent: markDone on an already-done story is a
|
|
2163
|
+
// no-op, and any plan-layer failure is recorded to the ledger but
|
|
2164
|
+
// never blocks the autopilot cycle.
|
|
2165
|
+
if (
|
|
2166
|
+
result.newState.phase === STATES.STORY_DONE &&
|
|
2167
|
+
result.newState.story_key &&
|
|
2168
|
+
typeof result.newState.story_key === 'string'
|
|
2169
|
+
) {
|
|
2170
|
+
try {
|
|
2171
|
+
const planRead = sprintPlanScript.read({ projectRoot });
|
|
2172
|
+
// Only update when a plan actually exists; greenfield projects
|
|
2173
|
+
// running in sprint-status order don't need plan upkeep.
|
|
2174
|
+
if (planRead && !(typeof planRead === 'object' && 'error' in planRead)) {
|
|
2175
|
+
sprintPlanScript.markDone(result.newState.story_key, { projectRoot });
|
|
2176
|
+
ledger.append(
|
|
2177
|
+
{ kind: 'plan_story_done', detail: { story_key: result.newState.story_key } },
|
|
2178
|
+
{ projectRoot },
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
} catch (e) {
|
|
2182
|
+
ledger.append(
|
|
2183
|
+
{
|
|
2184
|
+
kind: 'plan_story_done_failed',
|
|
2185
|
+
detail: { story_key: result.newState.story_key, message: e.message },
|
|
2186
|
+
},
|
|
2187
|
+
{ projectRoot },
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1745
2192
|
// Story-boundary or halt → flush coalesce buffer if enabled.
|
|
1746
2193
|
const isStoryBoundary =
|
|
1747
2194
|
result.newState.phase === STATES.STORY_DONE ||
|
|
@@ -1813,10 +2260,247 @@ function cmdStatus(opts) {
|
|
|
1813
2260
|
return 0;
|
|
1814
2261
|
}
|
|
1815
2262
|
|
|
2263
|
+
// v2.3.0 Phase 4.5 — `autopilot progress` CLI subcommand. Reads
|
|
2264
|
+
// sprint-plan.yaml + the recent ledger tail to produce a snapshot of
|
|
2265
|
+
// "what's running right now and what's done". Modes:
|
|
2266
|
+
// (default --once) Human-readable one-shot snapshot.
|
|
2267
|
+
// --json Machine-readable JSON for IDE extensions.
|
|
2268
|
+
// --story <key> Narrow to a single story.
|
|
2269
|
+
// Full --watch (ANSI cursor control / live redraw) is intentionally
|
|
2270
|
+
// deferred — terminals vary too widely to do right in this scope;
|
|
2271
|
+
// `watch -n 1 'autopilot progress'` covers the use case adequately.
|
|
2272
|
+
function cmdProgress(opts) {
|
|
2273
|
+
const projectRoot = resolveProjectRoot(opts);
|
|
2274
|
+
const persisted = loadState(projectRoot);
|
|
2275
|
+
const planResult = sprintPlanScript.read({ projectRoot });
|
|
2276
|
+
const plan =
|
|
2277
|
+
planResult && !(typeof planResult === 'object' && 'error' in planResult) ? planResult : null;
|
|
2278
|
+
|
|
2279
|
+
// Recent ledger events (last 50) for context. Includes step events
|
|
2280
|
+
// when Phase 4.5 emission is active.
|
|
2281
|
+
const recentEvents = ledger.read({ projectRoot }, { limit: 50 });
|
|
2282
|
+
const stepEvents = recentEvents.filter(
|
|
2283
|
+
(e) =>
|
|
2284
|
+
e.kind === 'story_step_started' ||
|
|
2285
|
+
e.kind === 'story_step_progress' ||
|
|
2286
|
+
e.kind === 'story_step_completed',
|
|
2287
|
+
);
|
|
2288
|
+
|
|
2289
|
+
// Build a story_key → issue_id lookup once so we can enrich every
|
|
2290
|
+
// reference (current story, recent events, etc.) without re-scanning
|
|
2291
|
+
// plan.stories each time.
|
|
2292
|
+
const issueIdByKey = new Map();
|
|
2293
|
+
if (plan && Array.isArray(plan.stories)) {
|
|
2294
|
+
for (const s of plan.stories) {
|
|
2295
|
+
if (s && typeof s.key === 'string' && typeof s.issue_id === 'string' && s.issue_id) {
|
|
2296
|
+
issueIdByKey.set(s.key, s.issue_id);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Compute progress stats from plan when available, fall back to
|
|
2302
|
+
// sprint-status if not.
|
|
2303
|
+
const stats = computeProgressStats(plan, persisted);
|
|
2304
|
+
// Issue-tracking coverage: how many stories in the plan have an
|
|
2305
|
+
// issue_id linked. Surfaced only when an issue_tracker is configured —
|
|
2306
|
+
// otherwise the field is meaningless noise.
|
|
2307
|
+
const issueTracking = computeIssueTracking(plan);
|
|
2308
|
+
const filterStory = opts.story || persisted.current_story || null;
|
|
2309
|
+
const currentIssueId = filterStory ? issueIdByKey.get(filterStory) || null : null;
|
|
2310
|
+
|
|
2311
|
+
// current_step falls back to the plan's per-story `current_step` field
|
|
2312
|
+
// (set by markRunning during cmdRecord) when no autopilot session is
|
|
2313
|
+
// running. Lets `autopilot progress --story X` show the last-known
|
|
2314
|
+
// phase even between sessions.
|
|
2315
|
+
let currentStep = persisted.current_bmad_step || null;
|
|
2316
|
+
if (!currentStep && filterStory && plan && Array.isArray(plan.stories)) {
|
|
2317
|
+
const entry = plan.stories.find((s) => s && s.key === filterStory);
|
|
2318
|
+
if (entry && typeof entry.current_step === 'string' && entry.current_step) {
|
|
2319
|
+
currentStep = entry.current_step;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const out = {
|
|
2324
|
+
project_root: projectRoot,
|
|
2325
|
+
plan_present: plan !== null,
|
|
2326
|
+
plan_id: plan ? plan.plan_id : null,
|
|
2327
|
+
issue_tracker: plan ? plan.issue_tracker || null : null,
|
|
2328
|
+
current_story: filterStory,
|
|
2329
|
+
current_step: currentStep,
|
|
2330
|
+
current_issue_id: currentIssueId,
|
|
2331
|
+
sprint_progress: stats,
|
|
2332
|
+
issue_tracking: issueTracking,
|
|
2333
|
+
recent_events: stepEvents.slice(-3).map((e) => {
|
|
2334
|
+
const storyKey = e.detail?.story_key || null;
|
|
2335
|
+
return {
|
|
2336
|
+
seq: e.seq,
|
|
2337
|
+
ts: e.ts,
|
|
2338
|
+
kind: e.kind,
|
|
2339
|
+
story_key: storyKey,
|
|
2340
|
+
step_name: e.detail?.step_name || null,
|
|
2341
|
+
outcome: e.detail?.outcome || null,
|
|
2342
|
+
// v2.3.0 — enrich with issue_id when the plan tracks one for
|
|
2343
|
+
// this story. Null when no plan or no issue_id set.
|
|
2344
|
+
issue_id: storyKey ? issueIdByKey.get(storyKey) || null : null,
|
|
2345
|
+
};
|
|
2346
|
+
}),
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
// If --story is set, also surface that story's plan entry.
|
|
2350
|
+
if (filterStory && plan && Array.isArray(plan.stories)) {
|
|
2351
|
+
const entry = plan.stories.find((s) => s && s.key === filterStory);
|
|
2352
|
+
if (entry) {
|
|
2353
|
+
out.story = {
|
|
2354
|
+
key: entry.key,
|
|
2355
|
+
epic: entry.epic,
|
|
2356
|
+
plan_status: entry.plan_status,
|
|
2357
|
+
current_step: entry.current_step || null,
|
|
2358
|
+
priority: entry.priority,
|
|
2359
|
+
bmad_status: entry.bmad_status,
|
|
2360
|
+
issue_id: entry.issue_id || null,
|
|
2361
|
+
completed_at: entry.completed_at || null,
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
if (opts.json) {
|
|
2367
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
2368
|
+
return 0;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// Human-readable rendering (line-append, CI-safe — no ANSI codes).
|
|
2372
|
+
const lines = [];
|
|
2373
|
+
if (!out.plan_present) {
|
|
2374
|
+
lines.push('Sprint plan: (none) — running in sprint-status order');
|
|
2375
|
+
} else {
|
|
2376
|
+
lines.push(`Sprint plan: plan_id=${out.plan_id}`);
|
|
2377
|
+
lines.push(
|
|
2378
|
+
`Progress: ${stats.done}/${stats.total} done` +
|
|
2379
|
+
(stats.skipped > 0 ? ` (${stats.skipped} skipped)` : '') +
|
|
2380
|
+
(stats.excluded > 0 ? `, ${stats.excluded} excluded` : '') +
|
|
2381
|
+
`, ${stats.pending} pending`,
|
|
2382
|
+
);
|
|
2383
|
+
lines.push(`Bar: ${renderProgressBar(stats.done, stats.total)}`);
|
|
2384
|
+
if (issueTracking && issueTracking.provider) {
|
|
2385
|
+
lines.push(
|
|
2386
|
+
`Issue tracking: ${issueTracking.linked}/${issueTracking.total} stories linked to ${issueTracking.provider}` +
|
|
2387
|
+
(issueTracking.project_key ? ` (${issueTracking.project_key})` : ''),
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
if (out.current_story) {
|
|
2392
|
+
const issueBracket = out.current_issue_id ? ` [${out.current_issue_id}]` : '';
|
|
2393
|
+
lines.push(
|
|
2394
|
+
`Current story: ${out.current_story}${issueBracket}` +
|
|
2395
|
+
(out.current_step ? ` (step: ${out.current_step})` : ''),
|
|
2396
|
+
);
|
|
2397
|
+
} else {
|
|
2398
|
+
lines.push('Current story: (none — between stories or idle)');
|
|
2399
|
+
}
|
|
2400
|
+
if (out.recent_events.length > 0) {
|
|
2401
|
+
lines.push('Recent step events:');
|
|
2402
|
+
for (const e of out.recent_events) {
|
|
2403
|
+
const storyLabel = e.story_key
|
|
2404
|
+
? e.issue_id
|
|
2405
|
+
? `${e.story_key} [${e.issue_id}]`
|
|
2406
|
+
: e.story_key
|
|
2407
|
+
: '-';
|
|
2408
|
+
lines.push(
|
|
2409
|
+
` [${e.seq}] ${e.ts.slice(11, 19)} ${e.kind.replace(/^story_/, '')} — ${storyLabel} / ${e.step_name || '-'}` +
|
|
2410
|
+
(e.outcome ? ` (${e.outcome})` : ''),
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
if (out.story) {
|
|
2415
|
+
lines.push('Story detail:');
|
|
2416
|
+
lines.push(` Key: ${out.story.key}`);
|
|
2417
|
+
lines.push(` Epic: ${out.story.epic ?? '-'}`);
|
|
2418
|
+
lines.push(` Plan status: ${out.story.plan_status ?? '-'}`);
|
|
2419
|
+
lines.push(` Bmad status: ${out.story.bmad_status ?? '-'}`);
|
|
2420
|
+
lines.push(` Priority: ${out.story.priority ?? '-'}`);
|
|
2421
|
+
if (out.story.current_step) {
|
|
2422
|
+
lines.push(` Current step: ${out.story.current_step}`);
|
|
2423
|
+
}
|
|
2424
|
+
lines.push(` Issue ID: ${out.story.issue_id || '(not set)'}`);
|
|
2425
|
+
if (out.story.completed_at) {
|
|
2426
|
+
lines.push(` Completed at: ${out.story.completed_at}`);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
2430
|
+
return 0;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// Compute issue-tracking coverage: how many of plan.stories[] have a
|
|
2434
|
+
// non-empty issue_id field. Returns null when no plan or no
|
|
2435
|
+
// issue_tracker is configured (irrelevant signal — skip the line entirely
|
|
2436
|
+
// in human output rather than spam zeros).
|
|
2437
|
+
function computeIssueTracking(plan) {
|
|
2438
|
+
if (!plan || !Array.isArray(plan.stories) || plan.stories.length === 0) return null;
|
|
2439
|
+
const tracker = plan.issue_tracker;
|
|
2440
|
+
if (!tracker || typeof tracker !== 'object' || !tracker.provider) return null;
|
|
2441
|
+
let linked = 0;
|
|
2442
|
+
for (const s of plan.stories) {
|
|
2443
|
+
if (s && typeof s.issue_id === 'string' && s.issue_id) linked += 1;
|
|
2444
|
+
}
|
|
2445
|
+
return {
|
|
2446
|
+
provider: tracker.provider,
|
|
2447
|
+
project_key: tracker.project_key || null,
|
|
2448
|
+
base_url: tracker.base_url || null,
|
|
2449
|
+
total: plan.stories.length,
|
|
2450
|
+
linked,
|
|
2451
|
+
coverage: plan.stories.length > 0 ? Math.round((linked / plan.stories.length) * 100) : 0,
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// Compute aggregate sprint progress from the plan (preferred) or fall
|
|
2456
|
+
// back to sprint-status counts. Returns counts keyed by plan_status.
|
|
2457
|
+
function computeProgressStats(plan, persisted) {
|
|
2458
|
+
if (plan && Array.isArray(plan.stories) && plan.stories.length > 0) {
|
|
2459
|
+
let done = 0;
|
|
2460
|
+
let pending = 0;
|
|
2461
|
+
let skipped = 0;
|
|
2462
|
+
let excluded = 0;
|
|
2463
|
+
for (const s of plan.stories) {
|
|
2464
|
+
if (!s) continue;
|
|
2465
|
+
if (s.plan_status === 'done') done += 1;
|
|
2466
|
+
else if (s.plan_status === 'skipped') skipped += 1;
|
|
2467
|
+
else if (s.plan_status === 'excluded') excluded += 1;
|
|
2468
|
+
else pending += 1;
|
|
2469
|
+
}
|
|
2470
|
+
return {
|
|
2471
|
+
total: plan.stories.length,
|
|
2472
|
+
done,
|
|
2473
|
+
pending,
|
|
2474
|
+
skipped,
|
|
2475
|
+
excluded,
|
|
2476
|
+
source: 'plan',
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
// Fallback: sprint-status. We already have persisted.story_queue length
|
|
2480
|
+
// as a soft proxy for pending; sprint-status itself drives the count.
|
|
2481
|
+
return {
|
|
2482
|
+
total: null,
|
|
2483
|
+
done: null,
|
|
2484
|
+
pending: Array.isArray(persisted.story_queue) ? persisted.story_queue.length : null,
|
|
2485
|
+
skipped: null,
|
|
2486
|
+
excluded: null,
|
|
2487
|
+
source: 'sprint-status',
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function renderProgressBar(done, total) {
|
|
2492
|
+
if (!total || total <= 0) return '(no plan stories)';
|
|
2493
|
+
const width = 30;
|
|
2494
|
+
const filled = Math.min(width, Math.max(0, Math.round((done / total) * width)));
|
|
2495
|
+
return `[${'='.repeat(filled)}${' '.repeat(width - filled)}] ${Math.round((done / total) * 100)}%`;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
1816
2498
|
// ------------------------------------------------------------ main
|
|
1817
2499
|
|
|
1818
2500
|
function main(argv) {
|
|
1819
|
-
const { opts, positional } = parseArgs(argv, {
|
|
2501
|
+
const { opts, positional } = parseArgs(argv, {
|
|
2502
|
+
booleanFlags: ['help', 'force', 'accept-divergence', 'no-auto-plan', 'json', 'once'],
|
|
2503
|
+
});
|
|
1820
2504
|
if (opts.help) {
|
|
1821
2505
|
help();
|
|
1822
2506
|
return 0;
|
|
@@ -1848,6 +2532,8 @@ function main(argv) {
|
|
|
1848
2532
|
return cmdValidateConfig(opts);
|
|
1849
2533
|
case 'status':
|
|
1850
2534
|
return cmdStatus(opts);
|
|
2535
|
+
case 'progress':
|
|
2536
|
+
return cmdProgress(opts);
|
|
1851
2537
|
default:
|
|
1852
2538
|
return 2;
|
|
1853
2539
|
}
|