@ikunin/sprintpilot 2.2.31 → 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 +734 -68
- 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 +78 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
- 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
|
|
@@ -349,9 +427,8 @@ function readSprintStatuses(projectRoot) {
|
|
|
349
427
|
// out-of-scope without lying that they shipped:
|
|
350
428
|
// - skipped / wont_do / cancelled / deferred — explicit user intent
|
|
351
429
|
// - abandoned — alternate spelling seen in the wild
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
// out the epic with a retrospective.
|
|
430
|
+
// Any entry in TERMINAL_STATUSES is treated as non-remaining for
|
|
431
|
+
// epic-done routing.
|
|
355
432
|
const TERMINAL_STATUSES = new Set([
|
|
356
433
|
'done',
|
|
357
434
|
'skipped',
|
|
@@ -493,13 +570,8 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
493
570
|
// skill invocation. PREPARE_STORY_BRANCH with no story_key would be
|
|
494
571
|
// a confusing emission to act on.
|
|
495
572
|
// Validate persisted.current_story against sprint-status before
|
|
496
|
-
// trusting it.
|
|
497
|
-
//
|
|
498
|
-
// resolveNextStoryKey scanned sprint-status without filtering. The
|
|
499
|
-
// v2.1.5 hotfix added looksLikeStoryKey but only at resolution time —
|
|
500
|
-
// already-persisted poisoned values ride forward through every
|
|
501
|
-
// upgrade, producing emissions like `branch: story/epic-4` on every
|
|
502
|
-
// session boot.
|
|
573
|
+
// trusting it. Persisted state can drift from reality when stories
|
|
574
|
+
// get renamed, deleted, or merged externally between sessions.
|
|
503
575
|
//
|
|
504
576
|
// Treat persisted.current_story as null when:
|
|
505
577
|
// - it doesn't look like a real story key (epic header, retro, garbage)
|
|
@@ -524,9 +596,9 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
524
596
|
// a poisoned-state signal when state.phase is a story-bound phase
|
|
525
597
|
// (CHECK_READINESS through STORY_LAND) — at STORY_DONE the story
|
|
526
598
|
// IS expected to be marked done in sprint-status (verifyStoryDone
|
|
527
|
-
// enforces it).
|
|
528
|
-
// story_key mid-record
|
|
529
|
-
// 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).
|
|
530
602
|
//
|
|
531
603
|
// Epic-rollup-header / retrospective / not-in-sprint-status
|
|
532
604
|
// rejections are ALWAYS poison and fire regardless of phase.
|
|
@@ -546,8 +618,7 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
546
618
|
process.stderr.write(
|
|
547
619
|
`[autopilot] WARN persisted current_story "${persistedCurrentStory}" rejected: ${rejection}. ` +
|
|
548
620
|
'Treating as null and falling through to queue / sprint-status resolution. ' +
|
|
549
|
-
'
|
|
550
|
-
'next emission will clean it up.\n',
|
|
621
|
+
'Next emission will clean it up.\n',
|
|
551
622
|
);
|
|
552
623
|
resolvedStoryKey = null;
|
|
553
624
|
resolvedEpic = null;
|
|
@@ -636,17 +707,15 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
636
707
|
}
|
|
637
708
|
}
|
|
638
709
|
|
|
639
|
-
// Count non-
|
|
710
|
+
// Count non-terminal stories in the current epic. state-machine.js's
|
|
640
711
|
// EPIC_BOUNDARY_CHECK reads this to decide between RETROSPECTIVE (end
|
|
641
|
-
// of epic, count === 0) and next-story-start (count > 0).
|
|
642
|
-
//
|
|
643
|
-
// — so the count stayed at 0 and EVERY story triggered a
|
|
644
|
-
// retrospective. Now: recompute from sprint-status.yaml each emission
|
|
645
|
-
// 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.
|
|
646
714
|
//
|
|
647
|
-
// Count semantics: excludes
|
|
648
|
-
//
|
|
649
|
-
//
|
|
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.
|
|
650
719
|
let remainingStoriesInEpic = persisted.remaining_stories_in_epic || 0;
|
|
651
720
|
if (resolvedEpic && projectRoot) {
|
|
652
721
|
const epicStories = resolveStoriesForEpic(projectRoot, resolvedEpic);
|
|
@@ -657,13 +726,11 @@ function composeRuntimeState(persisted, profile, projectRoot) {
|
|
|
657
726
|
// coherent action AND we still don't have one after every resolution
|
|
658
727
|
// path (queue / validator / sprint-status), reset phase to flowStart.
|
|
659
728
|
//
|
|
660
|
-
// Real-world scenario:
|
|
661
|
-
//
|
|
662
|
-
// reset
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// guard catches that case + any future bug class where story_key
|
|
666
|
-
// 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.
|
|
667
734
|
//
|
|
668
735
|
// The reset is safe: the next emission re-enters story-start (or
|
|
669
736
|
// PREPARE_STORY_BRANCH per the migration rule) and picks the next
|
|
@@ -878,13 +945,10 @@ function decorateGitOp(action, state, profile, projectRoot) {
|
|
|
878
945
|
}
|
|
879
946
|
}
|
|
880
947
|
|
|
881
|
-
// run_script actions for op=land_story carry only metadata
|
|
882
|
-
// land_when, squash_on_merge, ...)
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
// land_as_you_go got a metadata-only action and had to improvise.
|
|
886
|
-
// Symmetric to decorateGitOp: call land.planLand(state, profile,
|
|
887
|
-
// 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.
|
|
888
952
|
function decorateRunScript(action, state, profile, projectRoot) {
|
|
889
953
|
if (!action || action.type !== 'run_script') return action;
|
|
890
954
|
if (action.op === 'land_story') {
|
|
@@ -1071,7 +1135,19 @@ function probeBranchExists(projectRoot, branch) {
|
|
|
1071
1135
|
|
|
1072
1136
|
// ------------------------------------------------------------ side effects
|
|
1073
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.
|
|
1074
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
|
+
};
|
|
1075
1151
|
for (const eff of sideEffects || []) {
|
|
1076
1152
|
switch (eff.kind) {
|
|
1077
1153
|
case 'append_decisions': {
|
|
@@ -1136,11 +1212,130 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
|
|
|
1136
1212
|
ledger.append({ ...eff, kind }, { projectRoot });
|
|
1137
1213
|
break;
|
|
1138
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
|
+
}
|
|
1139
1333
|
default:
|
|
1140
1334
|
// Unknown side-effect kinds are recorded but otherwise ignored.
|
|
1141
1335
|
ledger.append({ kind: 'state_transition', detail: eff }, { projectRoot });
|
|
1142
1336
|
}
|
|
1143
1337
|
}
|
|
1338
|
+
return surfaceFailure;
|
|
1144
1339
|
}
|
|
1145
1340
|
|
|
1146
1341
|
// ------------------------------------------------------------ subcommands
|
|
@@ -1506,34 +1701,30 @@ function cmdStart(opts) {
|
|
|
1506
1701
|
);
|
|
1507
1702
|
}
|
|
1508
1703
|
|
|
1509
|
-
// parallel_stories: surface
|
|
1510
|
-
//
|
|
1511
|
-
// (planBatch, dispatch-layer.js, agent-adapter.js,
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
// time. A user who sets `ma.parallel_stories: true` and doesn't see
|
|
1515
|
-
// 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.
|
|
1516
1709
|
if (profile.parallel_stories) {
|
|
1517
1710
|
ledger.append(
|
|
1518
1711
|
{
|
|
1519
1712
|
kind: 'state_transition',
|
|
1520
1713
|
detail: {
|
|
1521
|
-
|
|
1522
|
-
'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.',
|
|
1523
1716
|
},
|
|
1524
1717
|
},
|
|
1525
1718
|
{ projectRoot },
|
|
1526
1719
|
);
|
|
1527
1720
|
process.stderr.write(
|
|
1528
|
-
'[autopilot]
|
|
1721
|
+
'[autopilot] NOTICE ma.parallel_stories=true honored at the dispatch-layer level; state-machine emission remains sequential in this build.\n',
|
|
1529
1722
|
);
|
|
1530
1723
|
}
|
|
1531
1724
|
if (profile.lint_enabled) {
|
|
1532
|
-
//
|
|
1725
|
+
// lint_enabled routes verifyDevGreen through post-green-gates.js
|
|
1533
1726
|
// (lint-changed + lint-test-pitfalls + ci-parity scan). lint_blocking
|
|
1534
|
-
// governs whether a failed gate
|
|
1535
|
-
// through with a warning. The v2.2.23 "not wired" warning is gone —
|
|
1536
|
-
// lint runs for real now.
|
|
1727
|
+
// governs whether a failed gate rejects verify or just records.
|
|
1537
1728
|
ledger.append(
|
|
1538
1729
|
{
|
|
1539
1730
|
kind: 'state_transition',
|
|
@@ -1580,6 +1771,175 @@ function cmdStart(opts) {
|
|
|
1580
1771
|
return 0;
|
|
1581
1772
|
}
|
|
1582
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
|
+
|
|
1583
1943
|
// Persist the new queue BEFORE composing runtime state so the queue
|
|
1584
1944
|
// head is visible to composeRuntimeState's resolver.
|
|
1585
1945
|
if (explicitQueue.length > 0) {
|
|
@@ -1733,7 +2093,34 @@ function cmdRecord(opts) {
|
|
|
1733
2093
|
}
|
|
1734
2094
|
|
|
1735
2095
|
const result = adapt.interpretSignal(runtime, signal, profile, verifyResult);
|
|
1736
|
-
|
|
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
|
+
}
|
|
1737
2124
|
|
|
1738
2125
|
// Skill timing: emit `skill.<name>` end event when an invoke_skill phase
|
|
1739
2126
|
// advances to a new phase (success path) OR when it pauses with a
|
|
@@ -1762,6 +2149,46 @@ function cmdRecord(opts) {
|
|
|
1762
2149
|
// Persist new runtime state.
|
|
1763
2150
|
persistRuntimeState(result.newState, result.newProfile, projectRoot);
|
|
1764
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
|
+
|
|
1765
2192
|
// Story-boundary or halt → flush coalesce buffer if enabled.
|
|
1766
2193
|
const isStoryBoundary =
|
|
1767
2194
|
result.newState.phase === STATES.STORY_DONE ||
|
|
@@ -1833,10 +2260,247 @@ function cmdStatus(opts) {
|
|
|
1833
2260
|
return 0;
|
|
1834
2261
|
}
|
|
1835
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
|
+
|
|
1836
2498
|
// ------------------------------------------------------------ main
|
|
1837
2499
|
|
|
1838
2500
|
function main(argv) {
|
|
1839
|
-
const { opts, positional } = parseArgs(argv, {
|
|
2501
|
+
const { opts, positional } = parseArgs(argv, {
|
|
2502
|
+
booleanFlags: ['help', 'force', 'accept-divergence', 'no-auto-plan', 'json', 'once'],
|
|
2503
|
+
});
|
|
1840
2504
|
if (opts.help) {
|
|
1841
2505
|
help();
|
|
1842
2506
|
return 0;
|
|
@@ -1868,6 +2532,8 @@ function main(argv) {
|
|
|
1868
2532
|
return cmdValidateConfig(opts);
|
|
1869
2533
|
case 'status':
|
|
1870
2534
|
return cmdStatus(opts);
|
|
2535
|
+
case 'progress':
|
|
2536
|
+
return cmdProgress(opts);
|
|
1871
2537
|
default:
|
|
1872
2538
|
return 2;
|
|
1873
2539
|
}
|