@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.
Files changed (34) hide show
  1. package/README.md +232 -413
  2. package/_Sprintpilot/Sprintpilot.md +76 -6
  3. package/_Sprintpilot/bin/autopilot.js +734 -68
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +208 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +93 -15
  6. package/_Sprintpilot/lib/orchestrator/profile-rules.js +7 -16
  7. package/_Sprintpilot/lib/orchestrator/sprint-plan.js +488 -0
  8. package/_Sprintpilot/lib/orchestrator/state-store.js +9 -5
  9. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +78 -0
  10. package/_Sprintpilot/lib/orchestrator/user-commands.js +114 -0
  11. package/_Sprintpilot/lib/orchestrator/verify.js +10 -17
  12. package/_Sprintpilot/manifest.yaml +4 -1
  13. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +18 -4
  14. package/_Sprintpilot/modules/git/config.yaml +15 -9
  15. package/_Sprintpilot/modules/ma/config.yaml +29 -27
  16. package/_Sprintpilot/scripts/dispatch-layer.js +12 -15
  17. package/_Sprintpilot/scripts/infer-dependencies.js +706 -254
  18. package/_Sprintpilot/scripts/log-timing.js +6 -10
  19. package/_Sprintpilot/scripts/merge-shards.js +21 -23
  20. package/_Sprintpilot/scripts/post-green-gates.js +3 -1
  21. package/_Sprintpilot/scripts/resolve-dag.js +452 -280
  22. package/_Sprintpilot/scripts/sprint-plan.js +1068 -0
  23. package/_Sprintpilot/scripts/state-shard.js +13 -5
  24. package/_Sprintpilot/scripts/summarize-timings.js +2 -3
  25. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +30 -2
  26. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +36 -10
  27. package/_Sprintpilot/skills/sprintpilot-dependency-graph/SKILL.md +63 -0
  28. package/_Sprintpilot/skills/sprintpilot-dependency-graph/workflow.md +227 -0
  29. package/_Sprintpilot/skills/sprintpilot-plan-sprint/SKILL.md +67 -0
  30. package/_Sprintpilot/skills/sprintpilot-plan-sprint/workflow.md +435 -0
  31. package/_Sprintpilot/skills/sprintpilot-sprint-progress/SKILL.md +53 -0
  32. package/_Sprintpilot/skills/sprintpilot-sprint-progress/workflow.md +169 -0
  33. package/lib/commands/install.js +186 -10
  34. 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 we don't ask the orchestrator to branch on an
153
- // epic identifier (the v2.1.4 hotfix shipped without this filter and
154
- // a user reported branch: story/epic-4 instead of story/4-8-...).
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)the v2.1.3/v2.1.4 poison
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) — v2.1.3/v2.1.4 poisoned state';
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
- // Catch only the documented poisoned shapes that pre-v2.1.5 orchestrator
200
- // versions could write to persisted.current_story:
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 composeRuntimeState in v2.1.3/v2.1.4 picked this as the
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). The v2.1.4 hotfix shipped without this filter and
227
- // the user reported `branch: story/epic-4` instead of the real next
228
- // pending story. The v2.1.5 hotfix extends the filter to retrospectives
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
- // Pre-2.2.31 only `done` counted, so any deferred 4-N story trapped the
353
- // orchestrator on next-story routing instead of letting the user close
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. Older orchestrator versions (v2.1.3 / v2.1.4) could
497
- // poison this field with an epic-rollup header (e.g. `epic-4`) when
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). Pre-2.2.9 fix: any "done" rejection nulled
528
- // story_key mid-record, producing branch "story/unknown" on
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
- 'This typically means state was poisoned by an older orchestrator version (v2.1.3 / v2.1.4 pre-filter); ' +
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-done stories in the current epic. state-machine.js's
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). Pre-2.2.2
642
- // this field was passthrough-only never written by the orchestrator
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 done stories AND non-story entries (epic
648
- // rollup headers, -retrospective entries) via the same looksLikeStoryKey
649
- // filter resolveNextStoryKey uses.
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: a previous orchestrator version nulled
661
- // current_story (e.g., v2.2.4's overzealous rejection) but didn't
662
- // reset state.phase. Persisted state ends up with current_story: null
663
- // at story_done. v2.2.9's reset only fires inside the rejection branch,
664
- // so a NULL story_key doesn't trigger it (no rejection to fire). This
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 (helper,
882
- // land_when, squash_on_merge, ...) the state machine's comment
883
- // promises "The CLI edge composes the actual argv via land.js#planLand"
884
- // but that wiring was missing pre-v2.2.12. Without it, LLMs driving
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 honestly that the documented flag is not
1510
- // yet wired through the BMad state machine. The supporting pieces
1511
- // (planBatch, dispatch-layer.js, agent-adapter.js, merge-shards.js)
1512
- // exist as building blocks but nextAction never emits a parallel_batch
1513
- // action every story still flows through the 7-phase cycle one at a
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
- parallel_stories_experimental_warning:
1522
- 'ma.parallel_stories=true is honored by the planBatch / dispatch-layer.js building blocks but the BMad state machine still emits one story at a time. Full intra-epic parallel dispatch is tracked for v2.3.0+. Stories continue sequentially in this session.',
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] WARN ma.parallel_stories=true but the state machine is not yet wired for parallel dispatch (planned for v2.3.0). Stories will run sequentially this session.\n',
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
- // v2.2.24: lint_enabled wires verifyDevGreen post-green-gates.js
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 halts the autopilot or passes
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
- applySideEffects(result.sideEffects, result.newState, result.newProfile, projectRoot);
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, { booleanFlags: ['help', 'force'] });
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
  }