@ikunin/sprintpilot 2.1.2 → 2.1.3

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.
@@ -86,6 +86,19 @@ function loadState(projectRoot) {
86
86
  return stateStore.read({ projectRoot });
87
87
  }
88
88
 
89
+ // Existence probe that never throws. Used by composeRuntimeState's
90
+ // migration guard so a stale `persisted.story_file_path` from before
91
+ // the file was actually written doesn't suppress migration. Returns
92
+ // false on any error (path is null/undefined, fs permission, etc.).
93
+ function safeExistsSync(p) {
94
+ if (!p || typeof p !== 'string') return false;
95
+ try {
96
+ return fs.existsSync(p);
97
+ } catch (_e) {
98
+ return false;
99
+ }
100
+ }
101
+
89
102
  function persistState(updates, profile, projectRoot, story) {
90
103
  return stateStore.write(updates, profile, { projectRoot, story });
91
104
  }
@@ -102,11 +115,71 @@ function persistState(updates, profile, projectRoot, story) {
102
115
  // orchestrator.md tells the LLM to call `next` directly, bypassing
103
116
  // cmdStart).
104
117
  function composeRuntimeState(persisted, profile) {
105
- const defaultPhase =
118
+ // Fresh-sprint initial phase. When git settings require a per-story or
119
+ // per-epic branch (granularity ∈ {story, epic} AND !reuse_user_branch
120
+ // AND git.enabled !== false), boot at PREPARE_STORY_BRANCH so the very
121
+ // first action is a `git_op: create_branch` — the story file is then
122
+ // authored on the story branch rather than on `main`.
123
+ //
124
+ // Skipped when:
125
+ // - reuse_user_branch: true → cmdStart detects + locks user branch
126
+ // - enabled: false → git is disabled entirely; no branch
127
+ // to prepare. State machine still emits
128
+ // git_ops at STORY_DONE but decorateGitOp
129
+ // empties their steps.
130
+ const needsBranchPrep =
131
+ profile &&
132
+ profile.enabled !== false &&
133
+ !profile.reuse_user_branch &&
134
+ (profile.granularity === 'story' || profile.granularity === 'epic');
135
+ const flowStart =
106
136
  profile && profile.implementation_flow === 'quick'
107
137
  ? STATES.NANO_QUICK_DEV
108
138
  : STATES.CREATE_STORY;
109
- const phase = persisted.current_bmad_step || defaultPhase;
139
+ const defaultPhase = needsBranchPrep ? STATES.PREPARE_STORY_BRANCH : flowStart;
140
+ let phase = persisted.current_bmad_step || defaultPhase;
141
+
142
+ // Phase enum validation: if persisted state has a garbage phase (typo
143
+ // / manual edit / pre-rename leftover), don't pass it through to
144
+ // nextAction which would throw "unknown phase" with a stack trace.
145
+ // Emit a clear warning and reset to the profile-aware default; the
146
+ // user can re-run after fixing the file or accept the reset.
147
+ const KNOWN_PHASES = new Set(Object.values(STATES));
148
+ if (!KNOWN_PHASES.has(phase)) {
149
+ process.stderr.write(
150
+ `[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`,
151
+ );
152
+ phase = defaultPhase;
153
+ }
154
+
155
+ // Migration: a sprint that was started before PREPARE_STORY_BRANCH
156
+ // shipped will have persisted `current_bmad_step: create_story` (or
157
+ // `nano_quick_dev`) with no story-level state set yet. On upgrade,
158
+ // route those fresh-story-start phases through PREPARE_STORY_BRANCH
159
+ // so the bug we fixed actually applies to existing sprints.
160
+ //
161
+ // Bail out if there's any sign of an in-flight story:
162
+ // - persisted.current_story set → story_key is being tracked
163
+ // - persisted.story_file_path set AND the file actually exists →
164
+ // bmad-create-story already wrote it; we'd lose work by re-routing
165
+ // - prior_diagnosis / retry_count_this_phase → mid-retry of this phase
166
+ // The file-exists check guards against stale persisted paths (e.g.
167
+ // when `coalesce_state_writes` persisted the field optimistically
168
+ // before the skill ran). Any genuine in-flight marker means mid-cycle.
169
+ const storyFileExists =
170
+ !!persisted.story_file_path && safeExistsSync(persisted.story_file_path);
171
+ const midStorySignals =
172
+ !!persisted.current_story ||
173
+ storyFileExists ||
174
+ !!persisted.prior_diagnosis ||
175
+ (persisted.retry_count_this_phase || 0) > 0;
176
+ if (
177
+ needsBranchPrep &&
178
+ !midStorySignals &&
179
+ (phase === STATES.CREATE_STORY || phase === STATES.NANO_QUICK_DEV)
180
+ ) {
181
+ phase = STATES.PREPARE_STORY_BRANCH;
182
+ }
110
183
  return {
111
184
  phase,
112
185
  story_key: persisted.current_story || null,
@@ -225,10 +298,55 @@ function logSkillTiming(projectRoot, event, story, skillName, profile) {
225
298
  // Inline the planned argv steps from git-plan.js so the LLM doesn't have
226
299
  // to interpret the op — it just executes `action.steps` in order.
227
300
  // Without this, live-LLM sessions silently skip `git push` after STORY_DONE.
228
- function decorateGitOp(action, state, profile) {
301
+ function decorateGitOp(action, state, profile, projectRoot) {
229
302
  if (!action || action.type !== 'git_op') return action;
303
+ // git.enabled: false — emit the git_op with an empty step list so the
304
+ // LLM's "execute steps in order" loop trivially succeeds and signals
305
+ // back, advancing the state machine without touching git. A bare
306
+ // `type: noop` would loop here because cmdNext re-emits the same phase
307
+ // until a success signal is recorded.
308
+ if (profile && profile.enabled === false) {
309
+ return {
310
+ ...action,
311
+ branch: null,
312
+ steps: [],
313
+ git_disabled: true,
314
+ };
315
+ }
230
316
  try {
231
- const planned = gitPlan.plan(state, profile, action);
317
+ // For create_branch: probe git locally to detect whether the planned
318
+ // branch already exists (resume after partial failure, second story
319
+ // on an epic branch under granularity=epic). The plan uses this to
320
+ // emit `git switch <branch>` (idempotent) instead of `git switch -c`
321
+ // (which would fail on collision). Probe is best-effort — failure
322
+ // leaves branch_exists false and the plan defaults to the create
323
+ // path (the safer default for fresh stories).
324
+ // Threading project_root onto the state so pure-ish helpers in
325
+ // git-plan.js can load files (pr_template_path) without taking it
326
+ // as a separate arg. The plan itself is still deterministic given
327
+ // the same inputs.
328
+ let enrichedState = { ...state, project_root: projectRoot || process.cwd() };
329
+ if (action.op === 'create_branch') {
330
+ const branch = gitPlan.branchName(profile, state.story_key, state.current_epic, state);
331
+ const branchExists = probeBranchExists(enrichedState.project_root, branch);
332
+ enrichedState = { ...enrichedState, branch_exists: branchExists };
333
+ }
334
+ const planned = gitPlan.plan(enrichedState, profile, action);
335
+ // Surface plan-level warnings (e.g. pr_template_path not found) via
336
+ // the orchestrator's stderr so the LLM context sees them, without
337
+ // git-plan.js itself writing to stderr from a pure-ish function.
338
+ if (Array.isArray(planned.warnings)) {
339
+ for (const w of planned.warnings) {
340
+ process.stderr.write(`[git-plan] WARN: ${w}\n`);
341
+ }
342
+ }
343
+ // Some plans (e.g. epic merge on unsupported platforms) return a
344
+ // `halt_action` field instead of executable steps. Convert it into
345
+ // a top-level user_prompt action so the orchestrator pauses instead
346
+ // of silently running zero steps and advancing.
347
+ if (planned.halt_action) {
348
+ return { ...planned.halt_action, phase: action.phase };
349
+ }
232
350
  return { ...action, branch: planned.branch, steps: planned.steps };
233
351
  } catch (e) {
234
352
  log.warn(`git-plan failed for op=${action.op}: ${e.message}`);
@@ -236,6 +354,69 @@ function decorateGitOp(action, state, profile) {
236
354
  }
237
355
  }
238
356
 
357
+ // Detect whether a branch exists, locally OR on origin. Used by
358
+ // decorateGitOp so the create_branch plan can degrade to a plain switch
359
+ // when the branch is already known. Checking remote refs avoids the
360
+ // failure mode where a teammate / prior worktree pushed the branch but
361
+ // it's not in our local refs — `git switch -c` would either fail or
362
+ // later collide on push. Returns false on any error so the create path
363
+ // (the safer default for fresh stories) is selected.
364
+ //
365
+ // Refreshes the local mirror of the specific remote ref via `git fetch
366
+ // origin <branch>` before checking refs/remotes/origin/<branch> — without
367
+ // this, a stale local clone can miss a recently-pushed remote branch.
368
+ // The fetch is best-effort and capped at 5s.
369
+ function probeBranchExists(projectRoot, branch) {
370
+ if (!branch || typeof branch !== 'string') return false;
371
+ const { execFileSync } = require('node:child_process');
372
+ // Local ref?
373
+ try {
374
+ execFileSync(
375
+ 'git',
376
+ ['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`],
377
+ { stdio: 'ignore', timeout: 5_000 },
378
+ );
379
+ return true;
380
+ } catch (_e) {
381
+ /* fall through */
382
+ }
383
+ // Skip the remote-ref dance entirely when there's no origin
384
+ // configured — every emit on a local-only repo would otherwise pay
385
+ // ~5s of fetch timeout for no gain. `git remote get-url origin`
386
+ // exits non-zero (~50ms) when origin is absent.
387
+ try {
388
+ execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', 'origin'], {
389
+ stdio: 'ignore',
390
+ timeout: 2_000,
391
+ });
392
+ } catch (_e) {
393
+ return false; // no origin → no remote ref to check
394
+ }
395
+ // Best-effort: refresh the remote ref before checking. Fetching a
396
+ // specific branch ref is much cheaper than `git fetch origin` (no
397
+ // tag/all-branch traffic) and is silent on a non-existent ref.
398
+ try {
399
+ execFileSync(
400
+ 'git',
401
+ ['-C', projectRoot, 'fetch', 'origin', branch, '--quiet', '--no-tags'],
402
+ { stdio: 'ignore', timeout: 5_000 },
403
+ );
404
+ } catch (_e) {
405
+ /* network / branch absent — fall through to local check */
406
+ }
407
+ // Remote ref now (possibly) up to date.
408
+ try {
409
+ execFileSync(
410
+ 'git',
411
+ ['-C', projectRoot, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`],
412
+ { stdio: 'ignore', timeout: 5_000 },
413
+ );
414
+ return true;
415
+ } catch (_e) {
416
+ return false;
417
+ }
418
+ }
419
+
239
420
  // ------------------------------------------------------------ side effects
240
421
 
241
422
  function applySideEffects(sideEffects, runtime, profile, projectRoot) {
@@ -312,6 +493,44 @@ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
312
493
 
313
494
  // ------------------------------------------------------------ subcommands
314
495
 
496
+ // Detect + lock the user's working branch under `reuse_user_branch:
497
+ // true`. Returns null if the runtime is already locked, or `{ halt }`
498
+ // with a halt/user_prompt action when the environment is invalid. Side-
499
+ // effect: mutates `runtime.user_branch` on success and appends a ledger
500
+ // entry. Used by both cmdStart and cmdNext so the LLM-direct path
501
+ // (workflow.orchestrator.md tells LLMs to call `next` without `start`)
502
+ // gets the same enforcement.
503
+ function lockUserBranchIfNeeded(runtime, profile, projectRoot) {
504
+ if (!profile.reuse_user_branch || runtime.user_branch) return null;
505
+ const current = detectCurrentBranch(projectRoot);
506
+ const base = profile.base_branch || 'main';
507
+ if (!current) {
508
+ return {
509
+ halt: {
510
+ type: 'halt',
511
+ reason: 'reuse_user_branch_no_git',
512
+ prompt:
513
+ '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.',
514
+ },
515
+ };
516
+ }
517
+ if (current === base) {
518
+ return {
519
+ halt: {
520
+ type: 'user_prompt',
521
+ reason: 'reuse_user_branch_on_base',
522
+ 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.`,
523
+ },
524
+ };
525
+ }
526
+ runtime.user_branch = current;
527
+ ledger.append(
528
+ { kind: 'state_transition', detail: { user_branch_detected: current } },
529
+ { projectRoot },
530
+ );
531
+ return null;
532
+ }
533
+
315
534
  function cmdStart(opts) {
316
535
  const projectRoot = resolveProjectRoot(opts);
317
536
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
@@ -337,41 +556,19 @@ function cmdStart(opts) {
337
556
  // profile-aware initial phase when persisted state is empty.
338
557
  const runtime = composeRuntimeState(persisted, profile);
339
558
 
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;
559
+ const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
560
+ if (lockResult && lockResult.halt) {
368
561
  ledger.append(
369
- { kind: 'state_transition', detail: { user_branch_detected: current } },
562
+ { kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
370
563
  { projectRoot },
371
564
  );
565
+ process.stdout.write(
566
+ `${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
567
+ );
568
+ return 0;
372
569
  }
373
570
 
374
- const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
571
+ const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
375
572
  ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
376
573
  persistRuntimeState(runtime, profile, projectRoot);
377
574
  if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: runtime.story_key });
@@ -384,8 +581,28 @@ function cmdNext(opts) {
384
581
  const { typed: profile } = resolveProfile(projectRoot, opts.profile);
385
582
  const persisted = loadState(projectRoot);
386
583
  const runtime = composeRuntimeState(persisted, profile);
387
- const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
584
+
585
+ // The LLM-driven workflow (workflow.orchestrator.md) tells the LLM to
586
+ // call `next` directly without `start` — apply the same branch-reuse
587
+ // enforcement here so a missed `start` doesn't bypass it.
588
+ const lockResult = lockUserBranchIfNeeded(runtime, profile, projectRoot);
589
+ if (lockResult && lockResult.halt) {
590
+ ledger.append(
591
+ { kind: 'action_emitted', phase: runtime.phase, action: lockResult.halt },
592
+ { projectRoot },
593
+ );
594
+ process.stdout.write(
595
+ `${JSON.stringify({ action: lockResult.halt, phase: runtime.phase }, null, 2)}\n`,
596
+ );
597
+ return 0;
598
+ }
599
+
600
+ const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile, projectRoot);
388
601
  ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
602
+ // Persist any mutations done by lockUserBranchIfNeeded — without this
603
+ // every cmdNext under reuse_user_branch=true re-detects the branch and
604
+ // emits a redundant `state_transition` ledger entry forever.
605
+ persistRuntimeState(runtime, profile, projectRoot);
389
606
  // Skill timing: emit a `skill.<name>` start event when we hand off an
390
607
  // invoke_skill action. The matching end event is emitted on `record`
391
608
  // when the signal advances the phase. This makes parallelism +
@@ -423,9 +640,17 @@ function cmdRecord(opts) {
423
640
  { projectRoot },
424
641
  );
425
642
 
426
- // Verify only on `success` and `verify_override`.
643
+ // Verify only on `success` and `verify_override`. Under `git.enabled:
644
+ // false`, git-op phases skip verify entirely — there's no commit_sha/
645
+ // branch to assert and verify would reject every success in a loop.
646
+ // The state machine still routes through these phases so the BMad
647
+ // cycle stays intact; only the bookkeeping check is bypassed. The
648
+ // phase list is centralized in state-machine.js#isGitOpPhase so a
649
+ // future git-op phase automatically gets the bypass.
650
+ const isGitDisabledPhase =
651
+ profile.enabled === false && stateMachine.shouldSkipVerifyWhenGitDisabled(runtime.phase);
427
652
  let verifyResult;
428
- if (signal.status === 'success') {
653
+ if (signal.status === 'success' && !isGitDisabledPhase) {
429
654
  verifyResult = verifyMod.verify(runtime, signal.output, { projectRoot });
430
655
  ledger.append(
431
656
  { kind: 'verify_result', phase: runtime.phase, ok: verifyResult.ok, issues: verifyResult.issues || [] },
@@ -499,7 +724,7 @@ function cmdRecord(opts) {
499
724
  }
500
725
 
501
726
  const payload = {
502
- action: decorateGitOp(result.nextAction, result.newState, result.newProfile),
727
+ action: decorateGitOp(result.nextAction, result.newState, result.newProfile, projectRoot),
503
728
  verdict: result.verdict,
504
729
  phase: result.newState.phase,
505
730
  profile: result.newProfile.name,
@@ -588,4 +813,4 @@ if (require.main === module) {
588
813
  process.exit(main(process.argv.slice(2)));
589
814
  }
590
815
 
591
- module.exports = { main, SUBCOMMANDS };
816
+ module.exports = { main, SUBCOMMANDS, decorateGitOp, composeRuntimeState };