@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.
@@ -71,18 +71,28 @@ function planLand(state, profile, options) {
71
71
  description: `snapshot stack for ${branch}`,
72
72
  });
73
73
 
74
- // Step 2: wait for CI / review depending on land_when.
74
+ // Step 2: wait for CI / review depending on land_when. Honors
75
+ // git.platform.provider (forwarded via --platform) so non-github
76
+ // providers route to the correct CLI / API path inside create-pr.js.
75
77
  if (landWhen === 'ci_pass' || landWhen === 'ci_and_review') {
78
+ const platform = (profile.platform_provider || options.platform || 'auto');
76
79
  const checkArgs = [
77
80
  'node',
78
81
  createPr,
79
82
  '--mode',
80
83
  'checks',
84
+ '--platform',
85
+ platform,
81
86
  '--branch',
82
87
  branch,
88
+ '--base',
89
+ baseBranch,
83
90
  '--wait-minutes',
84
91
  String(waitMinutes),
85
92
  ];
93
+ if (profile.platform_base_url) {
94
+ checkArgs.push('--base-url', profile.platform_base_url);
95
+ }
86
96
  if (landWhen === 'ci_and_review') {
87
97
  checkArgs.push('--require-approved-review');
88
98
  }
@@ -18,6 +18,10 @@ const VALID_RETRO_MODES = ['auto', 'stop', 'skip'];
18
18
  const VALID_GRANULARITIES = ['story', 'epic'];
19
19
  const VALID_MERGE_STRATEGIES = ['stacked', 'land_as_you_go'];
20
20
  const VALID_LAND_WHENS = ['no_wait', 'ci_pass', 'ci_and_review'];
21
+ const VALID_PLATFORM_PROVIDERS = ['auto', 'github', 'gitlab', 'bitbucket', 'gitea', 'git_only'];
22
+
23
+ const DEFAULT_COMMIT_TEMPLATE_STORY = 'feat({epic}): {story-title} ({story-key})';
24
+ const DEFAULT_COMMIT_TEMPLATE_PATCH = 'fix({story-key}): {patch-title}';
21
25
 
22
26
  // Per-profile defaults for fields the orchestrator manages directly
23
27
  // (verify_reject_budget, retry_budget_per_action). These are orchestrator-
@@ -59,6 +63,16 @@ function coerceEnum(v, allowed, fallback) {
59
63
  return fallback;
60
64
  }
61
65
 
66
+ // Compute land_wait_minutes (default 30) and epic_merge_wait_minutes
67
+ // (falls back to land_wait_minutes). Kept as a helper so the fallback
68
+ // chain is readable; inlining produced a nested coerceInt call that
69
+ // was hard to scan.
70
+ function resolveWaitMinutes(resolved) {
71
+ const land = coerceInt(get(resolved, 'git.land_wait_minutes'), 30);
72
+ const epic = coerceInt(get(resolved, 'git.epic_merge_wait_minutes'), land);
73
+ return { land_wait_minutes: land, epic_merge_wait_minutes: epic };
74
+ }
75
+
62
76
  // Convert the flat resolved-config tree (from resolve-profile.js) into a
63
77
  // typed Profile. Missing keys fall back to documented defaults.
64
78
  function flatToProfile(resolved, profileName) {
@@ -90,7 +104,7 @@ function flatToProfile(resolved, profileName) {
90
104
  'stacked',
91
105
  ),
92
106
  land_when: coerceEnum(get(resolved, 'git.land_when'), VALID_LAND_WHENS, 'ci_pass'),
93
- land_wait_minutes: coerceInt(get(resolved, 'git.land_wait_minutes'), 30),
107
+ ...resolveWaitMinutes(resolved),
94
108
  base_branch:
95
109
  typeof get(resolved, 'git.base_branch') === 'string'
96
110
  ? get(resolved, 'git.base_branch')
@@ -99,6 +113,53 @@ function flatToProfile(resolved, profileName) {
99
113
  typeof get(resolved, 'git.branch_prefix') === 'string'
100
114
  ? get(resolved, 'git.branch_prefix')
101
115
  : 'story/',
116
+ // git.enabled — when false, every `git_op` action emitted by the
117
+ // state machine is replaced with a `noop` at decorateGitOp time.
118
+ // Used for evaluation / dry-run setups where the user wants the
119
+ // BMad cycle to run but doesn't want any commits / pushes / PRs.
120
+ enabled: coerceBool(get(resolved, 'git.enabled'), true),
121
+ // git.push.auto — when false, planCommitAndPush drops the push
122
+ // steps (both story-branch and base-branch). Branches stay local.
123
+ push_auto: coerceBool(get(resolved, 'git.push.auto'), true),
124
+ // git.push.create_pr — when true (and merge_strategy=stacked),
125
+ // planCommitAndPush appends a `create-pr.js` step after the push so
126
+ // each story branch gets one PR opened automatically. land_as_you_go
127
+ // already opens its own PRs via land.js, so this knob doesn't gate
128
+ // that path.
129
+ push_create_pr: coerceBool(get(resolved, 'git.push.create_pr'), true),
130
+ pr_template_path:
131
+ typeof get(resolved, 'git.push.pr_template') === 'string'
132
+ ? get(resolved, 'git.push.pr_template')
133
+ : null,
134
+ // Commit message templates. Placeholders expanded in git-plan.js:
135
+ // {story-key} — state.story_key
136
+ // {epic} — state.current_epic (or derived from story_key)
137
+ // {story-title} — state.ac_summary or story_key as fallback
138
+ // {patch-title} — set on patch commits (bmad-dev-story owns those)
139
+ commit_template_story:
140
+ typeof get(resolved, 'git.commit_templates.story') === 'string'
141
+ ? get(resolved, 'git.commit_templates.story')
142
+ : DEFAULT_COMMIT_TEMPLATE_STORY,
143
+ commit_template_patch:
144
+ typeof get(resolved, 'git.commit_templates.patch') === 'string'
145
+ ? get(resolved, 'git.commit_templates.patch')
146
+ : DEFAULT_COMMIT_TEMPLATE_PATCH,
147
+ // git.max_branch_length — branchName() truncates long branch names
148
+ // (story keys + prefix) to this length with a 6-char hash suffix to
149
+ // keep the name unique. Honors the contract advertised in config.yaml.
150
+ max_branch_length: coerceInt(get(resolved, 'git.max_branch_length'), 60),
151
+ // git.platform.provider + base_url — forwarded to create-pr.js when
152
+ // the orchestrator opens or polls PRs. 'auto' delegates platform
153
+ // detection to create-pr.js (currently defaults to github).
154
+ platform_provider: coerceEnum(
155
+ get(resolved, 'git.platform.provider'),
156
+ VALID_PLATFORM_PROVIDERS,
157
+ 'auto',
158
+ ),
159
+ platform_base_url:
160
+ typeof get(resolved, 'git.platform.base_url') === 'string'
161
+ ? get(resolved, 'git.platform.base_url')
162
+ : null,
102
163
  parallel_stories: coerceBool(get(resolved, 'ma.parallel_stories'), false),
103
164
  max_parallel_stories: coerceInt(get(resolved, 'ma.max_parallel_stories'), 2),
104
165
  fallback_on_tests_fail: coerceBool(
@@ -161,6 +222,9 @@ module.exports = {
161
222
  VALID_GRANULARITIES,
162
223
  VALID_MERGE_STRATEGIES,
163
224
  VALID_LAND_WHENS,
225
+ VALID_PLATFORM_PROVIDERS,
226
+ DEFAULT_COMMIT_TEMPLATE_STORY,
227
+ DEFAULT_COMMIT_TEMPLATE_PATCH,
164
228
  ORCHESTRATOR_DEFAULTS_BY_PROFILE,
165
229
  flatToProfile,
166
230
  escalateOnFailure,
@@ -41,6 +41,14 @@
41
41
  'use strict';
42
42
 
43
43
  const STATES = Object.freeze({
44
+ // PREPARE_STORY_BRANCH — emitted as the first state of a fresh story
45
+ // when the active git settings require a per-story or per-epic branch
46
+ // (granularity ∈ {story, epic} AND !reuse_user_branch). Resolves to a
47
+ // git_op with op: 'create_branch' so the story file itself is authored
48
+ // on the story branch. Under `reuse_user_branch: true` this state is
49
+ // skipped and CREATE_STORY / NANO_QUICK_DEV runs directly on the
50
+ // user-locked branch.
51
+ PREPARE_STORY_BRANCH: 'prepare_story_branch',
44
52
  CREATE_STORY: 'create_story',
45
53
  CHECK_READINESS: 'check_readiness',
46
54
  DEV_RED: 'dev_red',
@@ -54,6 +62,16 @@ const STATES = Object.freeze({
54
62
  // story's PR into base. Skipped (STORY_DONE → EPIC_BOUNDARY_CHECK directly)
55
63
  // under the default 'stacked' strategy.
56
64
  STORY_LAND: 'story_land',
65
+ // MERGE_EPIC — entered from EPIC_BOUNDARY_CHECK when:
66
+ // • end-of-epic (remaining_stories_in_epic === 0)
67
+ // • granularity === 'epic'
68
+ // • merge_strategy === 'stacked'
69
+ // • push_auto === true, has_origin !== false, !reuse_user_branch
70
+ // Closes out the epic branch by either merging its PR (push_create_pr=
71
+ // true) or local-merging directly to base (push_create_pr=false). The
72
+ // existing planMergeEpic builds the local-merge sequence; planMergeEpicPr
73
+ // builds the gh-cli sequence.
74
+ MERGE_EPIC: 'merge_epic',
57
75
  EPIC_BOUNDARY_CHECK: 'epic_boundary_check',
58
76
  RETROSPECTIVE: 'retrospective',
59
77
  SPRINT_FINALIZE_PENDING: 'sprint_finalize_pending',
@@ -68,6 +86,7 @@ const TERMINAL_STATES = new Set([STATES.SPRINT_FINALIZE_PENDING]);
68
86
  // edges (e.g. patch_apply only when findings.action==='patch') are
69
87
  // enforced in `nextStateAfterSuccess`.
70
88
  const FULL_FLOW_SUCCESSORS = {
89
+ [STATES.PREPARE_STORY_BRANCH]: [STATES.CREATE_STORY],
71
90
  [STATES.CREATE_STORY]: [STATES.CHECK_READINESS],
72
91
  [STATES.CHECK_READINESS]: [STATES.DEV_RED],
73
92
  [STATES.DEV_RED]: [STATES.DEV_GREEN],
@@ -77,16 +96,39 @@ const FULL_FLOW_SUCCESSORS = {
77
96
  [STATES.PATCH_RETEST]: [STATES.CODE_REVIEW, STATES.STORY_DONE], // conditional (re-review if still blocking)
78
97
  [STATES.STORY_DONE]: [STATES.STORY_LAND, STATES.EPIC_BOUNDARY_CHECK], // STORY_LAND only under land_as_you_go
79
98
  [STATES.STORY_LAND]: [STATES.EPIC_BOUNDARY_CHECK],
80
- [STATES.EPIC_BOUNDARY_CHECK]: [STATES.RETROSPECTIVE, STATES.CREATE_STORY, STATES.SPRINT_FINALIZE_PENDING],
81
- [STATES.RETROSPECTIVE]: [STATES.CREATE_STORY, STATES.SPRINT_FINALIZE_PENDING],
99
+ [STATES.EPIC_BOUNDARY_CHECK]: [
100
+ STATES.MERGE_EPIC,
101
+ STATES.RETROSPECTIVE,
102
+ STATES.PREPARE_STORY_BRANCH,
103
+ STATES.CREATE_STORY,
104
+ STATES.SPRINT_FINALIZE_PENDING,
105
+ ],
106
+ [STATES.MERGE_EPIC]: [STATES.RETROSPECTIVE, STATES.SPRINT_FINALIZE_PENDING],
107
+ [STATES.RETROSPECTIVE]: [
108
+ STATES.PREPARE_STORY_BRANCH,
109
+ STATES.CREATE_STORY,
110
+ STATES.SPRINT_FINALIZE_PENDING,
111
+ ],
82
112
  };
83
113
 
84
114
  const NANO_FLOW_SUCCESSORS = {
115
+ [STATES.PREPARE_STORY_BRANCH]: [STATES.NANO_QUICK_DEV],
85
116
  [STATES.NANO_QUICK_DEV]: [STATES.STORY_DONE],
86
117
  [STATES.STORY_DONE]: [STATES.STORY_LAND, STATES.EPIC_BOUNDARY_CHECK],
87
118
  [STATES.STORY_LAND]: [STATES.EPIC_BOUNDARY_CHECK],
88
- [STATES.EPIC_BOUNDARY_CHECK]: [STATES.RETROSPECTIVE, STATES.NANO_QUICK_DEV, STATES.SPRINT_FINALIZE_PENDING],
89
- [STATES.RETROSPECTIVE]: [STATES.NANO_QUICK_DEV, STATES.SPRINT_FINALIZE_PENDING],
119
+ [STATES.EPIC_BOUNDARY_CHECK]: [
120
+ STATES.MERGE_EPIC,
121
+ STATES.RETROSPECTIVE,
122
+ STATES.PREPARE_STORY_BRANCH,
123
+ STATES.NANO_QUICK_DEV,
124
+ STATES.SPRINT_FINALIZE_PENDING,
125
+ ],
126
+ [STATES.MERGE_EPIC]: [STATES.RETROSPECTIVE, STATES.SPRINT_FINALIZE_PENDING],
127
+ [STATES.RETROSPECTIVE]: [
128
+ STATES.PREPARE_STORY_BRANCH,
129
+ STATES.NANO_QUICK_DEV,
130
+ STATES.SPRINT_FINALIZE_PENDING,
131
+ ],
90
132
  };
91
133
 
92
134
  // Build instruction template content slots from state + profile. This is the
@@ -130,6 +172,21 @@ function nextAction(state, profile) {
130
172
  }
131
173
 
132
174
  switch (state.phase) {
175
+ case STATES.PREPARE_STORY_BRANCH:
176
+ // The edge layer (autopilot.js#decorateGitOp) inlines the planned
177
+ // argv steps via git-plan.js#planCreateBranch. It also probes git
178
+ // for branch existence and threads `state.branch_exists` through
179
+ // so the plan can degrade `git switch -c` to `git switch` when the
180
+ // branch already exists (e.g. second story under granularity=epic,
181
+ // or resume after partial failure).
182
+ return {
183
+ type: 'git_op',
184
+ phase: state.phase,
185
+ op: 'create_branch',
186
+ story_key: state.story_key,
187
+ epic_key: state.current_epic,
188
+ profile: profile.name,
189
+ };
133
190
  case STATES.CREATE_STORY:
134
191
  return {
135
192
  type: 'invoke_skill',
@@ -194,6 +251,19 @@ function nextAction(state, profile) {
194
251
  story_key: state.story_key,
195
252
  profile: profile.name,
196
253
  };
254
+ case STATES.MERGE_EPIC:
255
+ // Edge layer (decorateGitOp) inlines the argv steps via
256
+ // git-plan.js#planMergeEpic. The plan branches internally on
257
+ // profile.push_create_pr to choose `gh pr merge --squash` vs the
258
+ // local-merge sequence.
259
+ return {
260
+ type: 'git_op',
261
+ phase: state.phase,
262
+ op: 'merge_epic',
263
+ story_key: state.story_key,
264
+ epic_key: state.current_epic,
265
+ profile: profile.name,
266
+ };
197
267
  case STATES.STORY_LAND:
198
268
  // Land-as-you-go: orchestrator plumbing emits a `run_script` that
199
269
  // wraps the existing stack-snapshot.js + land-this-pr.js scripts.
@@ -288,6 +358,13 @@ function nextStateAfterSuccess(currentState, profile, signal) {
288
358
  function deterministicNext(state, profile, output) {
289
359
  const phase = state.phase;
290
360
  switch (phase) {
361
+ case STATES.PREPARE_STORY_BRANCH: {
362
+ // Branch is on disk → enter the actual story work (flow-dependent).
363
+ const next = profile.implementation_flow === 'quick'
364
+ ? STATES.NANO_QUICK_DEV
365
+ : STATES.CREATE_STORY;
366
+ return { chosen: next, allValid: [next] };
367
+ }
291
368
  case STATES.CREATE_STORY:
292
369
  return { chosen: STATES.CHECK_READINESS, allValid: [STATES.CHECK_READINESS] };
293
370
  case STATES.CHECK_READINESS:
@@ -330,6 +407,12 @@ function deterministicNext(state, profile, output) {
330
407
  const sprintDone = !!state.sprint_is_complete;
331
408
  // End of epic?
332
409
  if (remainingInEpic <= 0) {
410
+ // Epic merge: granularity=epic + stacked + autoremote-push +
411
+ // !reuse_user_branch + git enabled → close out the epic branch
412
+ // (MERGE_EPIC) before retrospective / next-epic routing.
413
+ if (epicMergeNeeded(profile)) {
414
+ return { chosen: STATES.MERGE_EPIC, allValid: [STATES.MERGE_EPIC] };
415
+ }
333
416
  if (profile.retrospective_mode === 'skip') {
334
417
  return {
335
418
  chosen: sprintDone ? STATES.SPRINT_FINALIZE_PENDING : nextStoryStart(profile),
@@ -341,6 +424,22 @@ function deterministicNext(state, profile, output) {
341
424
  // More stories in the same epic.
342
425
  return { chosen: nextStoryStart(profile), allValid: [nextStoryStart(profile)] };
343
426
  }
427
+ case STATES.MERGE_EPIC: {
428
+ const sprintDone = !!state.sprint_is_complete;
429
+ const successor = nextStoryStart(profile);
430
+ // Structurally-valid successors per FULL/NANO_FLOW_SUCCESSORS:
431
+ // [RETROSPECTIVE, SPRINT_FINALIZE_PENDING]. Include the next-story
432
+ // start under retro=skip so the hint tiebreaker can route to it
433
+ // when the LLM has a strong opinion.
434
+ const allValid = [STATES.RETROSPECTIVE, STATES.SPRINT_FINALIZE_PENDING, successor];
435
+ if (profile.retrospective_mode === 'skip') {
436
+ return {
437
+ chosen: sprintDone ? STATES.SPRINT_FINALIZE_PENDING : successor,
438
+ allValid,
439
+ };
440
+ }
441
+ return { chosen: STATES.RETROSPECTIVE, allValid };
442
+ }
344
443
  case STATES.RETROSPECTIVE: {
345
444
  const sprintDone = !!state.sprint_is_complete;
346
445
  const chosen = sprintDone ? STATES.SPRINT_FINALIZE_PENDING : nextStoryStart(profile);
@@ -355,13 +454,66 @@ function deterministicNext(state, profile, output) {
355
454
  }
356
455
  }
357
456
 
457
+ // nextStoryStart(profile) — the first phase of a fresh story.
458
+ //
459
+ // Under settings that require a per-story or per-epic branch
460
+ // (granularity ∈ {story, epic} AND !reuse_user_branch) the very first
461
+ // phase is PREPARE_STORY_BRANCH so the branch exists before the story
462
+ // file is authored. Otherwise we skip straight to the flow-appropriate
463
+ // implementation step.
464
+ //
465
+ // The `reuse_user_branch: true` path is handled by autopilot.js#cmdStart
466
+ // which detects the current branch and locks it in via state.user_branch;
467
+ // PREPARE_STORY_BRANCH is unnecessary in that mode (every story commits
468
+ // to the same already-checked-out branch).
469
+ // epicMergeNeeded(profile) — true when EPIC_BOUNDARY_CHECK at end-of-epic
470
+ // should route through MERGE_EPIC instead of jumping straight to
471
+ // retrospective / next-story. Matches the same triggers as the per-story
472
+ // PR/merge in planCommitAndPush, just shifted to the epic-branch flow.
473
+ function epicMergeNeeded(profile) {
474
+ return (
475
+ profile &&
476
+ profile.enabled !== false &&
477
+ !profile.reuse_user_branch &&
478
+ profile.granularity === 'epic' &&
479
+ (profile.merge_strategy || 'stacked') === 'stacked' &&
480
+ profile.push_auto !== false &&
481
+ profile.has_origin !== false
482
+ );
483
+ }
484
+
485
+ // shouldSkipVerifyWhenGitDisabled(phase) — true when verify.js should
486
+ // be bypassed under `git.enabled: false`. This covers phases that emit
487
+ // a git_op (PREPARE_STORY_BRANCH, STORY_DONE, MERGE_EPIC) and STORY_LAND
488
+ // (which emits a run_script but reports the same kind of post-merge
489
+ // bookkeeping that requires git operations to have happened).
490
+ //
491
+ // Centralizing the list here means new phases of either kind won't
492
+ // silently miss the verify-skip wiring — add them to the set below.
493
+ // (Previously named `isGitOpPhase`; renamed because STORY_LAND is not
494
+ // strictly a git_op and the old name lied.)
495
+ const GIT_INTERACTING_PHASES = new Set([
496
+ STATES.PREPARE_STORY_BRANCH,
497
+ STATES.STORY_DONE,
498
+ STATES.MERGE_EPIC,
499
+ STATES.STORY_LAND,
500
+ ]);
501
+ function shouldSkipVerifyWhenGitDisabled(phase) {
502
+ return GIT_INTERACTING_PHASES.has(phase);
503
+ }
504
+
358
505
  function nextStoryStart(profile) {
506
+ const needsBranchPrep =
507
+ !profile.reuse_user_branch &&
508
+ (profile.granularity === 'story' || profile.granularity === 'epic');
509
+ if (needsBranchPrep) return STATES.PREPARE_STORY_BRANCH;
359
510
  return profile.implementation_flow === 'quick' ? STATES.NANO_QUICK_DEV : STATES.CREATE_STORY;
360
511
  }
361
512
 
362
513
  // Best-effort mapping from a next_skill_hint string (e.g. "bmad-code-review")
363
514
  // to a phase identifier. Used only as a tiebreaker.
364
515
  const HINT_TO_PHASE = {
516
+ prepare_story_branch: STATES.PREPARE_STORY_BRANCH,
365
517
  'bmad-create-story': STATES.CREATE_STORY,
366
518
  'bmad-check-implementation-readiness': STATES.CHECK_READINESS,
367
519
  'bmad-dev-story:red': STATES.DEV_RED,
@@ -372,6 +524,7 @@ const HINT_TO_PHASE = {
372
524
  'bmad-retrospective': STATES.RETROSPECTIVE,
373
525
  'bmad-quick-dev': STATES.NANO_QUICK_DEV,
374
526
  story_done: STATES.STORY_DONE,
527
+ merge_epic: STATES.MERGE_EPIC,
375
528
  sprint_finalize_pending: STATES.SPRINT_FINALIZE_PENDING,
376
529
  };
377
530
 
@@ -396,6 +549,8 @@ module.exports = {
396
549
  nextStateAfterSuccess,
397
550
  // Exposed for adapt.js to construct fresh-story states.
398
551
  nextStoryStart,
552
+ // Exposed for autopilot.js verify-skip routing.
553
+ shouldSkipVerifyWhenGitDisabled,
399
554
  // Exposed for tests / inspection.
400
555
  buildTemplateSlots,
401
556
  HINT_TO_PHASE,
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.1.2
3
+ version: 2.1.3
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules:
@@ -40,6 +40,14 @@ git:
40
40
  # the orchestrator halts and prompts the user.
41
41
  land_wait_minutes: 30
42
42
 
43
+ # Max minutes to wait for CI green on the epic branch before MERGE_EPIC
44
+ # invokes `gh pr merge`. Only consulted under granularity=epic +
45
+ # merge_strategy=stacked. Falls back to `land_wait_minutes` if unset,
46
+ # so existing configs with only the land knob keep working. Effective
47
+ # wall time can exceed this by up to ~60s due to the polling cycle
48
+ # (gh pr checks timeout + 30s±5s sleep) — see create-pr.js#runChecksMode.
49
+ epic_merge_wait_minutes: 30
50
+
43
51
  # Branch naming
44
52
  branch_prefix: "story/" # prefix for story branches (e.g., story/1-2-user-auth)
45
53
  max_branch_length: 60 # truncate + 6-char hash if longer