@ikunin/sprintpilot 2.1.2 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,10 @@ const userCommands = require('../lib/orchestrator/user-commands');
39
39
  const divergence = require('../lib/orchestrator/divergence');
40
40
  const reportRenderer = require('../lib/orchestrator/report');
41
41
  const gitPlan = require('../lib/orchestrator/git-plan');
42
+ const {
43
+ parseStatuses: parseSprintStatuses,
44
+ remainingFrom: remainingStoriesFrom,
45
+ } = require('../scripts/list-remaining-stories');
42
46
 
43
47
  const { STATES } = stateMachine;
44
48
 
@@ -86,6 +90,62 @@ function loadState(projectRoot) {
86
90
  return stateStore.read({ projectRoot });
87
91
  }
88
92
 
93
+ // Existence probe that never throws. Used by composeRuntimeState's
94
+ // migration guard so a stale `persisted.story_file_path` from before
95
+ // the file was actually written doesn't suppress migration. Returns
96
+ // false on any error (path is null/undefined, fs permission, etc.).
97
+ function safeExistsSync(p) {
98
+ if (!p || typeof p !== 'string') return false;
99
+ try {
100
+ return fs.existsSync(p);
101
+ } catch (_e) {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ // Resolve the next pending story key from BMad's sprint-status.yaml.
107
+ // Used by composeRuntimeState to populate state.story_key BEFORE
108
+ // emitting PREPARE_STORY_BRANCH — without this, branchName() falls
109
+ // back to "story/unknown" because state.story_key is null on a fresh
110
+ // sprint (CREATE_STORY hasn't run yet; for nano there's no CREATE_STORY
111
+ // at all and quick-dev reads sprint-status itself). Returns the first
112
+ // story whose status is NOT "done" (case-insensitive), or null when:
113
+ // - the status file doesn't exist (pre-planning)
114
+ // - all stories are done (sprint complete)
115
+ // - the file can't be parsed
116
+ function resolveNextStoryKey(projectRoot) {
117
+ if (!projectRoot) return null;
118
+ const sprintStatusPath = path.join(
119
+ projectRoot,
120
+ '_bmad-output',
121
+ 'implementation-artifacts',
122
+ 'sprint-status.yaml',
123
+ );
124
+ if (!safeExistsSync(sprintStatusPath)) return null;
125
+ try {
126
+ const raw = fs.readFileSync(sprintStatusPath, 'utf8');
127
+ const stories = parseSprintStatuses(raw);
128
+ const remaining = remainingStoriesFrom(stories);
129
+ return remaining.length > 0 ? remaining[0] : null;
130
+ } catch (_e) {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ // Derive the epic identifier from a BMad story key. Convention:
136
+ // `epic-N-slug` → `epic-N`; `<epic>-<story>-<slug>` → `<epic>`.
137
+ // Returns null when the key doesn't parse cleanly. Kept in sync with
138
+ // adapt.js#deriveEpicKey; centralized here so composeRuntimeState
139
+ // doesn't have to import adapt's private helper.
140
+ function deriveEpicFromStoryKey(storyKey) {
141
+ if (typeof storyKey !== 'string' || !storyKey) return null;
142
+ const epicPrefixed = storyKey.match(/^(epic-[A-Za-z0-9_]+)-/);
143
+ if (epicPrefixed) return epicPrefixed[1];
144
+ const firstSeg = storyKey.match(/^([A-Za-z0-9_]+)-/);
145
+ if (firstSeg) return firstSeg[1];
146
+ return null;
147
+ }
148
+
89
149
  function persistState(updates, profile, projectRoot, story) {
90
150
  return stateStore.write(updates, profile, { projectRoot, story });
91
151
  }
@@ -101,17 +161,111 @@ function persistState(updates, profile, projectRoot, story) {
101
161
  // regardless of which CLI entrypoint composed the runtime (workflow.
102
162
  // orchestrator.md tells the LLM to call `next` directly, bypassing
103
163
  // cmdStart).
104
- function composeRuntimeState(persisted, profile) {
105
- const defaultPhase =
164
+ function composeRuntimeState(persisted, profile, projectRoot) {
165
+ // Fresh-sprint initial phase. When git settings require a per-story or
166
+ // per-epic branch (granularity ∈ {story, epic} AND !reuse_user_branch
167
+ // AND git.enabled !== false), boot at PREPARE_STORY_BRANCH so the very
168
+ // first action is a `git_op: create_branch` — the story file is then
169
+ // authored on the story branch rather than on `main`.
170
+ //
171
+ // Skipped when:
172
+ // - reuse_user_branch: true → cmdStart detects + locks user branch
173
+ // - enabled: false → git is disabled entirely; no branch
174
+ // to prepare. State machine still emits
175
+ // git_ops at STORY_DONE but decorateGitOp
176
+ // empties their steps.
177
+ const needsBranchPrep =
178
+ profile &&
179
+ profile.enabled !== false &&
180
+ !profile.reuse_user_branch &&
181
+ (profile.granularity === 'story' || profile.granularity === 'epic');
182
+ const flowStart =
106
183
  profile && profile.implementation_flow === 'quick'
107
184
  ? STATES.NANO_QUICK_DEV
108
185
  : STATES.CREATE_STORY;
109
- const phase = persisted.current_bmad_step || defaultPhase;
186
+ const defaultPhase = needsBranchPrep ? STATES.PREPARE_STORY_BRANCH : flowStart;
187
+ let phase = persisted.current_bmad_step || defaultPhase;
188
+
189
+ // Phase enum validation: if persisted state has a garbage phase (typo
190
+ // / manual edit / pre-rename leftover), don't pass it through to
191
+ // nextAction which would throw "unknown phase" with a stack trace.
192
+ // Emit a clear warning and reset to the profile-aware default; the
193
+ // user can re-run after fixing the file or accept the reset.
194
+ const KNOWN_PHASES = new Set(Object.values(STATES));
195
+ if (!KNOWN_PHASES.has(phase)) {
196
+ process.stderr.write(
197
+ `[autopilot] WARN persisted current_bmad_step "${phase}" is not a known phase — resetting to ${defaultPhase}. Edit autopilot-state.yaml or run \`autopilot start\` to override.\n`,
198
+ );
199
+ phase = defaultPhase;
200
+ }
201
+
202
+ // Migration: a sprint that was started before PREPARE_STORY_BRANCH
203
+ // shipped will have persisted `current_bmad_step: create_story` (or
204
+ // `nano_quick_dev`) with no story-level state set yet. On upgrade,
205
+ // route those fresh-story-start phases through PREPARE_STORY_BRANCH
206
+ // so the bug we fixed actually applies to existing sprints.
207
+ //
208
+ // Bail out if there's any sign of an in-flight story:
209
+ // - persisted.current_story set → story_key is being tracked
210
+ // - persisted.story_file_path set AND the file actually exists →
211
+ // bmad-create-story already wrote it; we'd lose work by re-routing
212
+ // - prior_diagnosis / retry_count_this_phase → mid-retry of this phase
213
+ // The file-exists check guards against stale persisted paths (e.g.
214
+ // when `coalesce_state_writes` persisted the field optimistically
215
+ // before the skill ran). Any genuine in-flight marker means mid-cycle.
216
+ const storyFileExists =
217
+ !!persisted.story_file_path && safeExistsSync(persisted.story_file_path);
218
+ const midStorySignals =
219
+ !!persisted.current_story ||
220
+ storyFileExists ||
221
+ !!persisted.prior_diagnosis ||
222
+ (persisted.retry_count_this_phase || 0) > 0;
223
+ if (
224
+ needsBranchPrep &&
225
+ !midStorySignals &&
226
+ (phase === STATES.CREATE_STORY || phase === STATES.NANO_QUICK_DEV)
227
+ ) {
228
+ phase = STATES.PREPARE_STORY_BRANCH;
229
+ }
230
+
231
+ // Resolve story_key for PREPARE_STORY_BRANCH. The branch creation step
232
+ // needs a known story_key (and current_epic under granularity=epic) to
233
+ // compute the branch name — without resolution, branchName() falls
234
+ // back to "story/unknown" and we'd push a useless ref to origin.
235
+ //
236
+ // Why this is needed: PREPARE_STORY_BRANCH runs BEFORE CREATE_STORY in
237
+ // the full flow (and NANO_QUICK_DEV picks the story itself in quick
238
+ // flow), so the story_key from persisted state can be null on a fresh
239
+ // sprint. Read sprint-status.yaml — the same source of truth bmad-
240
+ // create-story / bmad-quick-dev use — to look ahead and find the
241
+ // next pending story.
242
+ //
243
+ // If nothing is pending (pre-planning OR sprint complete OR parse
244
+ // failure), fall back to flowStart so the LLM gets a meaningful
245
+ // skill invocation. PREPARE_STORY_BRANCH with no story_key would be
246
+ // a confusing emission to act on.
247
+ let resolvedStoryKey = persisted.current_story || null;
248
+ let resolvedEpic = persisted.current_epic || null;
249
+ let resolvedStoryFilePath = persisted.story_file_path || null;
250
+ if (phase === STATES.PREPARE_STORY_BRANCH && !resolvedStoryKey) {
251
+ const next = resolveNextStoryKey(projectRoot);
252
+ if (next) {
253
+ resolvedStoryKey = next;
254
+ if (!resolvedEpic) resolvedEpic = deriveEpicFromStoryKey(next);
255
+ } else {
256
+ process.stderr.write(
257
+ `[autopilot] WARN PREPARE_STORY_BRANCH needs a next-story key but sprint-status.yaml has none pending — falling back to ${flowStart}. ` +
258
+ 'Run BMad sprint-planning first, or set git.reuse_user_branch=true to commit on the current branch.\n',
259
+ );
260
+ phase = flowStart;
261
+ }
262
+ }
263
+
110
264
  return {
111
265
  phase,
112
- story_key: persisted.current_story || null,
113
- story_file_path: persisted.story_file_path || null,
114
- current_epic: persisted.current_epic || null,
266
+ story_key: resolvedStoryKey,
267
+ story_file_path: resolvedStoryFilePath,
268
+ current_epic: resolvedEpic,
115
269
  ac_summary: persisted.ac_summary || null,
116
270
  prior_diagnosis: persisted.prior_diagnosis || null,
117
271
  relevant_decisions: persisted.relevant_decisions || [],
@@ -225,10 +379,55 @@ function logSkillTiming(projectRoot, event, story, skillName, profile) {
225
379
  // Inline the planned argv steps from git-plan.js so the LLM doesn't have
226
380
  // to interpret the op — it just executes `action.steps` in order.
227
381
  // Without this, live-LLM sessions silently skip `git push` after STORY_DONE.
228
- function decorateGitOp(action, state, profile) {
382
+ function decorateGitOp(action, state, profile, projectRoot) {
229
383
  if (!action || action.type !== 'git_op') return action;
384
+ // git.enabled: false — emit the git_op with an empty step list so the
385
+ // LLM's "execute steps in order" loop trivially succeeds and signals
386
+ // back, advancing the state machine without touching git. A bare
387
+ // `type: noop` would loop here because cmdNext re-emits the same phase
388
+ // until a success signal is recorded.
389
+ if (profile && profile.enabled === false) {
390
+ return {
391
+ ...action,
392
+ branch: null,
393
+ steps: [],
394
+ git_disabled: true,
395
+ };
396
+ }
230
397
  try {
231
- const planned = gitPlan.plan(state, profile, action);
398
+ // For create_branch: probe git locally to detect whether the planned
399
+ // branch already exists (resume after partial failure, second story
400
+ // on an epic branch under granularity=epic). The plan uses this to
401
+ // emit `git switch <branch>` (idempotent) instead of `git switch -c`
402
+ // (which would fail on collision). Probe is best-effort — failure
403
+ // leaves branch_exists false and the plan defaults to the create
404
+ // path (the safer default for fresh stories).
405
+ // Threading project_root onto the state so pure-ish helpers in
406
+ // git-plan.js can load files (pr_template_path) without taking it
407
+ // as a separate arg. The plan itself is still deterministic given
408
+ // the same inputs.
409
+ let enrichedState = { ...state, project_root: projectRoot || process.cwd() };
410
+ if (action.op === 'create_branch') {
411
+ const branch = gitPlan.branchName(profile, state.story_key, state.current_epic, state);
412
+ const branchExists = probeBranchExists(enrichedState.project_root, branch);
413
+ enrichedState = { ...enrichedState, branch_exists: branchExists };
414
+ }
415
+ const planned = gitPlan.plan(enrichedState, profile, action);
416
+ // Surface plan-level warnings (e.g. pr_template_path not found) via
417
+ // the orchestrator's stderr so the LLM context sees them, without
418
+ // git-plan.js itself writing to stderr from a pure-ish function.
419
+ if (Array.isArray(planned.warnings)) {
420
+ for (const w of planned.warnings) {
421
+ process.stderr.write(`[git-plan] WARN: ${w}\n`);
422
+ }
423
+ }
424
+ // Some plans (e.g. epic merge on unsupported platforms) return a
425
+ // `halt_action` field instead of executable steps. Convert it into
426
+ // a top-level user_prompt action so the orchestrator pauses instead
427
+ // of silently running zero steps and advancing.
428
+ if (planned.halt_action) {
429
+ return { ...planned.halt_action, phase: action.phase };
430
+ }
232
431
  return { ...action, branch: planned.branch, steps: planned.steps };
233
432
  } catch (e) {
234
433
  log.warn(`git-plan failed for op=${action.op}: ${e.message}`);
@@ -236,6 +435,69 @@ function decorateGitOp(action, state, profile) {
236
435
  }
237
436
  }
238
437
 
438
+ // Detect whether a branch exists, locally OR on origin. Used by
439
+ // decorateGitOp so the create_branch plan can degrade to a plain switch
440
+ // when the branch is already known. Checking remote refs avoids the
441
+ // failure mode where a teammate / prior worktree pushed the branch but
442
+ // it's not in our local refs — `git switch -c` would either fail or
443
+ // later collide on push. Returns false on any error so the create path
444
+ // (the safer default for fresh stories) is selected.
445
+ //
446
+ // Refreshes the local mirror of the specific remote ref via `git fetch
447
+ // origin <branch>` before checking refs/remotes/origin/<branch> — without
448
+ // this, a stale local clone can miss a recently-pushed remote branch.
449
+ // The fetch is best-effort and capped at 5s.
450
+ function probeBranchExists(projectRoot, branch) {
451
+ if (!branch || typeof branch !== 'string') return false;
452
+ const { execFileSync } = require('node:child_process');
453
+ // Local ref?
454
+ try {
455
+ execFileSync(
456
+ 'git',
457
+ ['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
458
+ { stdio: 'ignore', timeout: 5_000 },
459
+ );
460
+ return true;
461
+ } catch (_e) {
462
+ /* fall through */
463
+ }
464
+ // Skip the remote-ref dance entirely when there's no origin
465
+ // configured — every emit on a local-only repo would otherwise pay
466
+ // ~5s of fetch timeout for no gain. `git remote get-url origin`
467
+ // exits non-zero (~50ms) when origin is absent.
468
+ try {
469
+ execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'], {
470
+ stdio: 'ignore',
471
+ timeout: 2_000,
472
+ });
473
+ } catch (_e) {
474
+ return false; // no origin → no remote ref to check
475
+ }
476
+ // Best-effort: refresh the remote ref before checking. Fetching a
477
+ // specific branch ref is much cheaper than `git fetch origin` (no
478
+ // tag/all-branch traffic) and is silent on a non-existent ref.
479
+ try {
480
+ execFileSync(
481
+ 'git',
482
+ ['-C', projectRoot, 'fetch', 'origin', branch, '--quiet', '--no-tags'],
483
+ { stdio: 'ignore', timeout: 5_000 },
484
+ );
485
+ } catch (_e) {
486
+ /* network / branch absent — fall through to local check */
487
+ }
488
+ // Remote ref now (possibly) up to date.
489
+ try {
490
+ execFileSync(
491
+ 'git',
492
+ ['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`],
493
+ { stdio: 'ignore', timeout: 5_000 },
494
+ );
495
+ return true;
496
+ } catch (_e) {
497
+ return false;
498
+ }
499
+ }
500
+
239
501
  // ------------------------------------------------------------ side effects
240
502
 
241
503
  function applySideEffects(sideEffects, runtime, profile, projectRoot) {
@@ -312,6 +574,44 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
312
574
 
313
575
  // ------------------------------------------------------------ subcommands
314
576
 
577
+ // Detect + lock the user's working branch under `reuse_user_branch:
578
+ // true`. Returns null if the runtime is already locked, or `{ halt }`
579
+ // with a halt/user_prompt action when the environment is invalid. Side-
580
+ // effect: mutates `runtime.user_branch` on success and appends a ledger
581
+ // entry. Used by both cmdStart and cmdNext so the LLM-direct path
582
+ // (workflow.orchestrator.md tells LLMs to call `next` without `start`)
583
+ // gets the same enforcement.
584
+ function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
585
+ if (!profile.reuse_user_branch || runtime.user_branch) return null;
586
+ const current = detectCurrentBranch(projectRoot);
587
+ const base = profile.base_branch || 'main';
588
+ if (!current) {
589
+ return {
590
+ halt: {
591
+ type: 'halt',
592
+ reason: 'reuse_user_branch_no_git',
593
+ prompt:
594
+ 'reuse_user_branch is on but git is not available / no current branch detected. Initialize a git repo and check out the branch you want autopilot to use.',
595
+ },
596
+ };
597
+ }
598
+ if (current === base) {
599
+ return {
600
+ halt: {
601
+ type: 'user_prompt',
602
+ reason: 'reuse_user_branch_on_base',
603
+ prompt: `reuse_user_branch is on but you're on the base branch (${base}). Create + checkout the branch you want autopilot to commit on, then re-run.`,
604
+ },
605
+ };
606
+ }
607
+ runtime.user_branch = current;
608
+ ledger.append(
609
+ { kind: 'state_transition', detail: { user_branch_detected: current } },
610
+ { projectRoot },
611
+ );
612
+ return null;
613
+ }
614
+
315
615
  function cmdStart(opts) {
316
616
  const projectRoot = resolveProjectRoot(opts);
317
617
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
@@ -335,43 +635,21 @@ function cmdStart(opts) {
335
635
 
336
636
  // Fresh start or clean resume. `composeRuntimeState` applies the
337
637
  // profile-aware initial phase when persisted state is empty.
338
- const runtime = composeRuntimeState(persisted, profile);
339
-
340
- // Branch reuse: on first boot under reuse_user_branch=true, detect the
341
- // current git branch and lock it in. The state machine + git-plan then
342
- // commit every story onto this branch.
343
- if (profile.reuse_user_branch && !runtime.user_branch) {
344
- const current = detectCurrentBranch(projectRoot);
345
- const base = profile.base_branch || 'main';
346
- if (!current) {
347
- const halt = {
348
- type: 'halt',
349
- reason: 'reuse_user_branch_no_git',
350
- prompt:
351
- 'reuse_user_branch is on but git is not available / no current branch detected. Initialize a git repo and check out the branch you want autopilot to use.',
352
- };
353
- ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
354
- process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
355
- return 0;
356
- }
357
- if (current === base) {
358
- const halt = {
359
- type: 'user_prompt',
360
- reason: 'reuse_user_branch_on_base',
361
- prompt: `reuse_user_branch is on but you're on the base branch (${base}). Create + checkout the branch you want autopilot to commit on, then re-run.`,
362
- };
363
- ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
364
- process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
365
- return 0;
366
- }
367
- runtime.user_branch = current;
638
+ const runtime = composeRuntimeState(persisted, profile, projectRoot);
639
+
640
+ const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
641
+ if (lockResult && lockResult.halt) {
368
642
  ledger.append(
369
- { kind: 'state_transition', detail: { user_branch_detected: current } },
643
+ { kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
370
644
  { projectRoot },
371
645
  );
646
+ process.stdout.write(
647
+ `${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
648
+ );
649
+ return 0;
372
650
  }
373
651
 
374
- const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
652
+ const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
375
653
  ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
376
654
  persistRuntimeState(runtime, profile, projectRoot);
377
655
  if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: runtime.story_key });
@@ -383,9 +661,29 @@ function cmdNext(opts) {
383
661
  const projectRoot = resolveProjectRoot(opts);
384
662
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
385
663
  const persisted = loadState(projectRoot);
386
- const runtime = composeRuntimeState(persisted, profile);
387
- const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
664
+ const runtime = composeRuntimeState(persisted, profile, projectRoot);
665
+
666
+ // The LLM-driven workflow (workflow.orchestrator.md) tells the LLM to
667
+ // call `next` directly without `start` — apply the same branch-reuse
668
+ // enforcement here so a missed `start` doesn't bypass it.
669
+ const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
670
+ if (lockResult && lockResult.halt) {
671
+ ledger.append(
672
+ { kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
673
+ { projectRoot },
674
+ );
675
+ process.stdout.write(
676
+ `${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
677
+ );
678
+ return 0;
679
+ }
680
+
681
+ const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
388
682
  ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
683
+ // Persist any mutations done by lockUserBranchIfNeeded — without this
684
+ // every cmdNext under reuse_user_branch=true re-detects the branch and
685
+ // emits a redundant `state_transition` ledger entry forever.
686
+ persistRuntimeState(runtime, profile, projectRoot);
389
687
  // Skill timing: emit a `skill.<name>` start event when we hand off an
390
688
  // invoke_skill action. The matching end event is emitted on `record`
391
689
  // when the signal advances the phase. This makes parallelism +
@@ -401,7 +699,7 @@ function cmdRecord(opts) {
401
699
  const projectRoot = resolveProjectRoot(opts);
402
700
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
403
701
  const persisted = loadState(projectRoot);
404
- const runtime = composeRuntimeState(persisted, profile);
702
+ const runtime = composeRuntimeState(persisted, profile, projectRoot);
405
703
 
406
704
  let signalJson;
407
705
  if (opts['signal-file']) {
@@ -423,9 +721,17 @@ function cmdRecord(opts) {
423
721
  { projectRoot },
424
722
  );
425
723
 
426
- // Verify only on `success` and `verify_override`.
724
+ // Verify only on `success` and `verify_override`. Under `git.enabled:
725
+ // false`, git-op phases skip verify entirely — there's no commit_sha/
726
+ // branch to assert and verify would reject every success in a loop.
727
+ // The state machine still routes through these phases so the BMad
728
+ // cycle stays intact; only the bookkeeping check is bypassed. The
729
+ // phase list is centralized in state-machine.js#isGitOpPhase so a
730
+ // future git-op phase automatically gets the bypass.
731
+ const isGitDisabledPhase =
732
+ profile.enabled === false && stateMachine.shouldSkipVerifyWhenGitDisabled(runtime.phase);
427
733
  let verifyResult;
428
- if (signal.status === 'success') {
734
+ if (signal.status === 'success' && !isGitDisabledPhase) {
429
735
  verifyResult = verifyMod.verify(runtime, signal.output, { projectRoot });
430
736
  ledger.append(
431
737
  { kind: 'verify_result', phase: runtime.phase, ok: verifyResult.ok, issues: verifyResult.issues || [] },
@@ -499,7 +805,7 @@ function cmdRecord(opts) {
499
805
  }
500
806
 
501
807
  const payload = {
502
- action: decorateGitOp(result.nextAction, result.newState, result.newProfile),
808
+ action: decorateGitOp(result.nextAction, result.newState, result.newProfile, projectRoot),
503
809
  verdict: result.verdict,
504
810
  phase: result.newState.phase,
505
811
  profile: result.newProfile.name,
@@ -588,4 +894,4 @@ if (require.main === module) {
588
894
  process.exit(main(process.argv.slice(2)));
589
895
  }
590
896
 
591
- module.exports = { main, SUBCOMMANDS };
897
+ module.exports = { main, SUBCOMMANDS, decorateGitOp, composeRuntimeState };
@@ -397,12 +397,31 @@ function handleUserInput(state, signal, profile, sideEffects) {
397
397
 
398
398
  // One-shot dispatch (e.g. accept_alternative resolved a pending alt)?
399
399
  // Return the dispatched action in place of the state-machine's default.
400
+ //
401
+ // Sync story metadata onto newState. The dispatched action carries
402
+ // story_key / current_epic / story_file_path / ac_summary in its
403
+ // `template_slots` (and sometimes as top-level fields on git_ops).
404
+ // Without propagating these, accept_alternative dispatches work on
405
+ // a specific story but autopilot-state.yaml still shows
406
+ // `current_story: null` — subsequent emissions / persists / verify
407
+ // checks all reference the wrong story.
400
408
  const dispatch = applied.sideEffects.find((e) => e && e.kind === 'dispatch_action');
401
409
  if (dispatch && dispatch.action) {
410
+ const a = dispatch.action;
411
+ const slots = a.template_slots || {};
412
+ const enrichedState = {
413
+ ...newState,
414
+ story_key: newState.story_key || slots.story_key || a.story_key || null,
415
+ current_epic:
416
+ newState.current_epic || slots.current_epic || a.epic_key || null,
417
+ story_file_path:
418
+ newState.story_file_path || slots.story_file_path || null,
419
+ ac_summary: newState.ac_summary || slots.ac_summary || null,
420
+ };
402
421
  return {
403
- newState,
422
+ newState: enrichedState,
404
423
  newProfile,
405
- nextAction: { ...dispatch.action, _dispatched_via: dispatch.reason || 'user_input' },
424
+ nextAction: { ...a, _dispatched_via: dispatch.reason || 'user_input' },
406
425
  sideEffects,
407
426
  verdict: 'advanced',
408
427
  };