@ikunin/sprintpilot 2.1.1 → 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.
- package/_Sprintpilot/bin/autopilot.js +278 -43
- package/_Sprintpilot/lib/orchestrator/adapt.js +62 -9
- package/_Sprintpilot/lib/orchestrator/git-plan.js +522 -36
- package/_Sprintpilot/lib/orchestrator/land.js +11 -1
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +65 -1
- package/_Sprintpilot/lib/orchestrator/state-machine.js +159 -4
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +50 -3
- package/_Sprintpilot/lib/orchestrator/user-commands.js +15 -7
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/scripts/create-pr.js +178 -7
- package/_Sprintpilot/scripts/run-step.js +221 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +37 -3
- package/lib/commands/install.js +197 -0
- package/lib/core/config-merger.js +238 -0
- package/lib/core/v2-upgrade-recovery.js +47 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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]: [
|
|
81
|
-
|
|
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]: [
|
|
89
|
-
|
|
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,
|
|
@@ -61,12 +61,15 @@ function applyOne(state, profile, cmd) {
|
|
|
61
61
|
|
|
62
62
|
case 'force_continue':
|
|
63
63
|
// Clears verify-reject + retry counters so the orchestrator stops
|
|
64
|
-
// looping on a stuck transition. Phase is unchanged.
|
|
64
|
+
// looping on a stuck transition. Phase is unchanged. Also clears
|
|
65
|
+
// any pending_alternative — `force_continue` is the explicit "no,
|
|
66
|
+
// keep the planned action" answer to a propose_alternative prompt.
|
|
65
67
|
newState = {
|
|
66
68
|
...state,
|
|
67
69
|
retry_count_this_phase: 0,
|
|
68
70
|
verify_reject_count: 0,
|
|
69
71
|
consecutive_test_failures: 0,
|
|
72
|
+
pending_alternative: undefined,
|
|
70
73
|
};
|
|
71
74
|
effects.push({
|
|
72
75
|
kind: 'state_transition',
|
|
@@ -74,6 +77,7 @@ function applyOne(state, profile, cmd) {
|
|
|
74
77
|
to: state.phase,
|
|
75
78
|
reason: 'user_force_continue',
|
|
76
79
|
details: cmd.reason || null,
|
|
80
|
+
cleared_pending_alternative: !!state.pending_alternative,
|
|
77
81
|
});
|
|
78
82
|
break;
|
|
79
83
|
|
|
@@ -100,8 +104,19 @@ function applyOne(state, profile, cmd) {
|
|
|
100
104
|
break;
|
|
101
105
|
|
|
102
106
|
case 'pause':
|
|
103
|
-
//
|
|
104
|
-
//
|
|
107
|
+
// Set `halt_requested` so adapt.nextAction returns a halt action
|
|
108
|
+
// on this same turn. Without this flag, prior versions of the
|
|
109
|
+
// applier only logged the halt side-effect and the orchestrator
|
|
110
|
+
// kept emitting the next planned action — the loop never stopped.
|
|
111
|
+
// `halt_requested` is cleared by `start` on the next session
|
|
112
|
+
// (same path that clears stale fingerprints on resume).
|
|
113
|
+
newState = {
|
|
114
|
+
...state,
|
|
115
|
+
halt_requested: {
|
|
116
|
+
reason: cmd.reason || null,
|
|
117
|
+
requested_at: new Date().toISOString(),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
105
120
|
effects.push({
|
|
106
121
|
kind: 'halt',
|
|
107
122
|
reason: 'user_pause',
|
|
@@ -109,6 +124,38 @@ function applyOne(state, profile, cmd) {
|
|
|
109
124
|
});
|
|
110
125
|
break;
|
|
111
126
|
|
|
127
|
+
case 'accept_alternative': {
|
|
128
|
+
// Dispatches the orchestrator's stored `pending_alternative` (set
|
|
129
|
+
// when handleProposeAlternative escalated to a user_prompt at
|
|
130
|
+
// medium/high impact). The CLI edge / adapt's handleUserInput
|
|
131
|
+
// looks for this side-effect and uses `action` as the one-shot
|
|
132
|
+
// nextAction in place of the state-machine default.
|
|
133
|
+
const pending = state.pending_alternative;
|
|
134
|
+
if (!pending || !pending.action) {
|
|
135
|
+
effects.push({
|
|
136
|
+
kind: 'validation_error',
|
|
137
|
+
reason: 'accept_alternative: no pending alternative to accept',
|
|
138
|
+
phase: state.phase,
|
|
139
|
+
details: cmd.reason || null,
|
|
140
|
+
});
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
newState = {
|
|
144
|
+
...state,
|
|
145
|
+
pending_alternative: undefined,
|
|
146
|
+
retry_count_this_phase: 0,
|
|
147
|
+
verify_reject_count: 0,
|
|
148
|
+
};
|
|
149
|
+
effects.push({
|
|
150
|
+
kind: 'dispatch_action',
|
|
151
|
+
action: pending.action,
|
|
152
|
+
impact: pending.impact || null,
|
|
153
|
+
reason: 'user_accept_alternative',
|
|
154
|
+
details: cmd.reason || null,
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
112
159
|
case 'override_decision':
|
|
113
160
|
// We don't apply a state mutation. The CLI records this so a
|
|
114
161
|
// subsequent verify_override can reference DEC-id.
|
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
// Pure module. No I/O.
|
|
8
8
|
//
|
|
9
9
|
// Command kinds (initial set; new kinds added via additive PR):
|
|
10
|
-
// skip_story
|
|
11
|
-
// abort_sprint
|
|
12
|
-
// force_continue
|
|
13
|
-
// override_decision
|
|
14
|
-
// change_profile
|
|
15
|
-
// pause
|
|
10
|
+
// skip_story { story_key: string, reason?: string }
|
|
11
|
+
// abort_sprint { reason?: string }
|
|
12
|
+
// force_continue { reason?: string }
|
|
13
|
+
// override_decision { decision_id: string, new_value: string }
|
|
14
|
+
// change_profile { profile: 'nano'|'small'|'medium'|'large'|'legacy' }
|
|
15
|
+
// pause { reason?: string }
|
|
16
|
+
// accept_alternative { reason?: string }
|
|
17
|
+
// Accepts the orchestrator's most recent `propose_alternative` that
|
|
18
|
+
// was escalated to a user_prompt at medium/high impact. Dispatches
|
|
19
|
+
// the stored alternative as the next action and clears the pending
|
|
20
|
+
// entry. Validation rejects this kind when no alternative is pending
|
|
21
|
+
// in state — see user-command-applier.js for the runtime check.
|
|
16
22
|
//
|
|
17
23
|
// Validation returns { ok: true, command } | { ok: false, errors: string[] }.
|
|
18
24
|
|
|
@@ -27,6 +33,7 @@ const COMMAND_KINDS = [
|
|
|
27
33
|
'override_decision',
|
|
28
34
|
'change_profile',
|
|
29
35
|
'pause',
|
|
36
|
+
'accept_alternative',
|
|
30
37
|
];
|
|
31
38
|
|
|
32
39
|
const STORY_KEY_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
@@ -65,7 +72,8 @@ function validateOne(cmd) {
|
|
|
65
72
|
}
|
|
66
73
|
case 'abort_sprint':
|
|
67
74
|
case 'force_continue':
|
|
68
|
-
case 'pause':
|
|
75
|
+
case 'pause':
|
|
76
|
+
case 'accept_alternative': {
|
|
69
77
|
if ('reason' in cmd && cmd.reason !== undefined && typeof cmd.reason !== 'string')
|
|
70
78
|
errors.push(`${cmd.kind}.reason must be string when present`);
|
|
71
79
|
break;
|
|
@@ -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
|