@ikunin/sprintpilot 2.0.10 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -10
- package/_Sprintpilot/Sprintpilot.md +1 -1
- package/_Sprintpilot/bin/autopilot.js +581 -0
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
- package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
- package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
- package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
- package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
- package/_Sprintpilot/lib/orchestrator/land.js +155 -0
- package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
- package/_Sprintpilot/lib/orchestrator/report.js +95 -0
- package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
- package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +26 -0
- package/_Sprintpilot/scripts/agent-adapter.js +4 -5
- package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
- package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
- package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
- package/_Sprintpilot/scripts/land-this-pr.js +110 -0
- package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
- package/_Sprintpilot/scripts/log-timing.js +12 -3
- package/_Sprintpilot/scripts/merge-shards.js +32 -12
- package/_Sprintpilot/scripts/post-green-gates.js +187 -0
- package/_Sprintpilot/scripts/preflight-merge.js +2 -1
- package/_Sprintpilot/scripts/resolve-dag.js +3 -1
- package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
- package/_Sprintpilot/scripts/state-shard.js +8 -1
- package/_Sprintpilot/scripts/summarize-timings.js +30 -12
- package/_Sprintpilot/scripts/with-retry.js +17 -5
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
- package/lib/core/update-check.js +11 -1
- package/package.json +1 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// state-machine.js — BMad 7-step state machine (pure, table-driven).
|
|
2
|
+
//
|
|
3
|
+
// States (story-scoped, in order):
|
|
4
|
+
// 1. create_story
|
|
5
|
+
// 2. check_readiness
|
|
6
|
+
// 3. dev_red
|
|
7
|
+
// 4. dev_green
|
|
8
|
+
// 5. code_review
|
|
9
|
+
// 6a. patch_apply (entered only if review has any 'patch' finding)
|
|
10
|
+
// 6b. patch_retest (entered after 6a)
|
|
11
|
+
// 7. story_done
|
|
12
|
+
// 8. epic_boundary_check
|
|
13
|
+
// 9. retrospective (per-epic; only when retrospective_mode != 'skip')
|
|
14
|
+
// 10. sprint_finalize_pending
|
|
15
|
+
//
|
|
16
|
+
// Nano profile collapses 1–6 into a single `nano_quick_dev` state that
|
|
17
|
+
// emits `invoke_skill: bmad-quick-dev`.
|
|
18
|
+
//
|
|
19
|
+
// Pure. nextAction(state, profile, world) → Action object.
|
|
20
|
+
//
|
|
21
|
+
// `state` is the orchestrator's runtime state shape:
|
|
22
|
+
// {
|
|
23
|
+
// phase, // one of STATES below
|
|
24
|
+
// story_key, // current story
|
|
25
|
+
// story_file_path, // resolved path for story file
|
|
26
|
+
// current_epic, // epic key for current story
|
|
27
|
+
// ac_summary, // compact AC summary
|
|
28
|
+
// prior_diagnosis, // last failure diagnosis (or null)
|
|
29
|
+
// relevant_decisions, // decision-log entries scoped to this story
|
|
30
|
+
// prior_signals_summary,
|
|
31
|
+
// patch_findings, // structured findings[] from review (when in step 6)
|
|
32
|
+
// tests_to_rerun,
|
|
33
|
+
// remaining_stories_in_epic, // count
|
|
34
|
+
// remaining_epics, // count
|
|
35
|
+
// sprint_is_complete,
|
|
36
|
+
// escalation_note, // when nano escalated, populated for template
|
|
37
|
+
// }
|
|
38
|
+
//
|
|
39
|
+
// `profile` is the typed Profile from profile-rules.js.
|
|
40
|
+
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
const STATES = Object.freeze({
|
|
44
|
+
CREATE_STORY: 'create_story',
|
|
45
|
+
CHECK_READINESS: 'check_readiness',
|
|
46
|
+
DEV_RED: 'dev_red',
|
|
47
|
+
DEV_GREEN: 'dev_green',
|
|
48
|
+
CODE_REVIEW: 'code_review',
|
|
49
|
+
PATCH_APPLY: 'patch_apply',
|
|
50
|
+
PATCH_RETEST: 'patch_retest',
|
|
51
|
+
STORY_DONE: 'story_done',
|
|
52
|
+
// STORY_LAND — entered only when profile.merge_strategy === 'land_as_you_go'.
|
|
53
|
+
// Composes stack-snapshot.js + land-this-pr.js to merge the just-finished
|
|
54
|
+
// story's PR into base. Skipped (STORY_DONE → EPIC_BOUNDARY_CHECK directly)
|
|
55
|
+
// under the default 'stacked' strategy.
|
|
56
|
+
STORY_LAND: 'story_land',
|
|
57
|
+
EPIC_BOUNDARY_CHECK: 'epic_boundary_check',
|
|
58
|
+
RETROSPECTIVE: 'retrospective',
|
|
59
|
+
SPRINT_FINALIZE_PENDING: 'sprint_finalize_pending',
|
|
60
|
+
// Nano-only collapsed state.
|
|
61
|
+
NANO_QUICK_DEV: 'nano_quick_dev',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const TERMINAL_STATES = new Set([STATES.SPRINT_FINALIZE_PENDING]);
|
|
65
|
+
|
|
66
|
+
// Successor table for the FULL flow. Used to enumerate "structurally valid
|
|
67
|
+
// successors" when a next_skill_hint disambiguation is needed. Conditional
|
|
68
|
+
// edges (e.g. patch_apply only when findings.action==='patch') are
|
|
69
|
+
// enforced in `nextStateAfterSuccess`.
|
|
70
|
+
const FULL_FLOW_SUCCESSORS = {
|
|
71
|
+
[STATES.CREATE_STORY]: [STATES.CHECK_READINESS],
|
|
72
|
+
[STATES.CHECK_READINESS]: [STATES.DEV_RED],
|
|
73
|
+
[STATES.DEV_RED]: [STATES.DEV_GREEN],
|
|
74
|
+
[STATES.DEV_GREEN]: [STATES.CODE_REVIEW],
|
|
75
|
+
[STATES.CODE_REVIEW]: [STATES.PATCH_APPLY, STATES.STORY_DONE], // conditional
|
|
76
|
+
[STATES.PATCH_APPLY]: [STATES.PATCH_RETEST],
|
|
77
|
+
[STATES.PATCH_RETEST]: [STATES.CODE_REVIEW, STATES.STORY_DONE], // conditional (re-review if still blocking)
|
|
78
|
+
[STATES.STORY_DONE]: [STATES.STORY_LAND, STATES.EPIC_BOUNDARY_CHECK], // STORY_LAND only under land_as_you_go
|
|
79
|
+
[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],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const NANO_FLOW_SUCCESSORS = {
|
|
85
|
+
[STATES.NANO_QUICK_DEV]: [STATES.STORY_DONE],
|
|
86
|
+
[STATES.STORY_DONE]: [STATES.STORY_LAND, STATES.EPIC_BOUNDARY_CHECK],
|
|
87
|
+
[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],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Build instruction template content slots from state + profile. This is the
|
|
93
|
+
// LLM-intelligence preservation channel: every skill invocation gets a rich
|
|
94
|
+
// context bundle, not a free-form prose blob.
|
|
95
|
+
function buildTemplateSlots(state, profile, extra = {}) {
|
|
96
|
+
return {
|
|
97
|
+
story_key: state.story_key || null,
|
|
98
|
+
story_file_path: state.story_file_path || null,
|
|
99
|
+
ac_summary: state.ac_summary || null,
|
|
100
|
+
prior_diagnosis: state.prior_diagnosis || null,
|
|
101
|
+
relevant_decisions: state.relevant_decisions || [],
|
|
102
|
+
prior_signals_summary: state.prior_signals_summary || null,
|
|
103
|
+
patch_findings: state.patch_findings || null,
|
|
104
|
+
tests_to_rerun: state.tests_to_rerun || null,
|
|
105
|
+
profile_name: profile.name,
|
|
106
|
+
profile_specific_notes: state.escalation_note || profileNotes(profile),
|
|
107
|
+
...extra,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function profileNotes(profile) {
|
|
112
|
+
if (profile.name === 'nano') {
|
|
113
|
+
return 'nano: bmad-quick-dev one-shot; escalate to full flow on test fail or high severity.';
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// nextAction(state, profile) → Action
|
|
119
|
+
// Returns the canonical action for the current `state.phase`.
|
|
120
|
+
function nextAction(state, profile) {
|
|
121
|
+
if (!state || !state.phase) {
|
|
122
|
+
throw new Error('nextAction: state.phase required');
|
|
123
|
+
}
|
|
124
|
+
if (state.sprint_is_complete && state.phase !== STATES.SPRINT_FINALIZE_PENDING) {
|
|
125
|
+
return {
|
|
126
|
+
type: 'halt',
|
|
127
|
+
reason: 'sprint_complete',
|
|
128
|
+
handoff: 'sprint_finalize_pending',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
switch (state.phase) {
|
|
133
|
+
case STATES.CREATE_STORY:
|
|
134
|
+
return {
|
|
135
|
+
type: 'invoke_skill',
|
|
136
|
+
skill: 'bmad-create-story',
|
|
137
|
+
phase: state.phase,
|
|
138
|
+
template: 'bmad-create-story.tmpl.md',
|
|
139
|
+
template_slots: buildTemplateSlots(state, profile),
|
|
140
|
+
};
|
|
141
|
+
case STATES.CHECK_READINESS:
|
|
142
|
+
return {
|
|
143
|
+
type: 'invoke_skill',
|
|
144
|
+
skill: 'bmad-check-implementation-readiness',
|
|
145
|
+
phase: state.phase,
|
|
146
|
+
template: 'bmad-check-implementation-readiness.tmpl.md',
|
|
147
|
+
template_slots: buildTemplateSlots(state, profile),
|
|
148
|
+
};
|
|
149
|
+
case STATES.DEV_RED:
|
|
150
|
+
return {
|
|
151
|
+
type: 'invoke_skill',
|
|
152
|
+
skill: 'bmad-dev-story',
|
|
153
|
+
phase: state.phase,
|
|
154
|
+
template: 'bmad-dev-story.red.tmpl.md',
|
|
155
|
+
template_slots: buildTemplateSlots(state, profile, { tdd_phase: 'red' }),
|
|
156
|
+
};
|
|
157
|
+
case STATES.DEV_GREEN:
|
|
158
|
+
return {
|
|
159
|
+
type: 'invoke_skill',
|
|
160
|
+
skill: 'bmad-dev-story',
|
|
161
|
+
phase: state.phase,
|
|
162
|
+
template: 'bmad-dev-story.green.tmpl.md',
|
|
163
|
+
template_slots: buildTemplateSlots(state, profile, { tdd_phase: 'green' }),
|
|
164
|
+
};
|
|
165
|
+
case STATES.CODE_REVIEW:
|
|
166
|
+
return {
|
|
167
|
+
type: 'invoke_skill',
|
|
168
|
+
skill: 'bmad-code-review',
|
|
169
|
+
phase: state.phase,
|
|
170
|
+
template: 'bmad-code-review.tmpl.md',
|
|
171
|
+
template_slots: buildTemplateSlots(state, profile),
|
|
172
|
+
};
|
|
173
|
+
case STATES.PATCH_APPLY:
|
|
174
|
+
return {
|
|
175
|
+
type: 'invoke_skill',
|
|
176
|
+
skill: 'bmad-dev-story',
|
|
177
|
+
phase: state.phase,
|
|
178
|
+
template: 'bmad-dev-story.patch.tmpl.md',
|
|
179
|
+
template_slots: buildTemplateSlots(state, profile, { tdd_phase: 'patch' }),
|
|
180
|
+
};
|
|
181
|
+
case STATES.PATCH_RETEST:
|
|
182
|
+
return {
|
|
183
|
+
type: 'invoke_skill',
|
|
184
|
+
skill: 'bmad-dev-story',
|
|
185
|
+
phase: state.phase,
|
|
186
|
+
template: 'bmad-dev-story.rereview.tmpl.md',
|
|
187
|
+
template_slots: buildTemplateSlots(state, profile, { tdd_phase: 'rereview' }),
|
|
188
|
+
};
|
|
189
|
+
case STATES.STORY_DONE:
|
|
190
|
+
return {
|
|
191
|
+
type: 'git_op',
|
|
192
|
+
phase: state.phase,
|
|
193
|
+
op: 'commit_and_push_story',
|
|
194
|
+
story_key: state.story_key,
|
|
195
|
+
profile: profile.name,
|
|
196
|
+
};
|
|
197
|
+
case STATES.STORY_LAND:
|
|
198
|
+
// Land-as-you-go: orchestrator plumbing emits a `run_script` that
|
|
199
|
+
// wraps the existing stack-snapshot.js + land-this-pr.js scripts.
|
|
200
|
+
// Honors land_when (no_wait | ci_pass | ci_and_review) and
|
|
201
|
+
// land_wait_minutes from the profile.
|
|
202
|
+
return {
|
|
203
|
+
type: 'run_script',
|
|
204
|
+
phase: state.phase,
|
|
205
|
+
op: 'land_story',
|
|
206
|
+
story_key: state.story_key,
|
|
207
|
+
profile: profile.name,
|
|
208
|
+
land_when: profile.land_when || 'ci_pass',
|
|
209
|
+
land_wait_minutes:
|
|
210
|
+
typeof profile.land_wait_minutes === 'number' ? profile.land_wait_minutes : 30,
|
|
211
|
+
squash_on_merge: !!profile.squash_on_merge,
|
|
212
|
+
// The CLI edge composes the actual argv via land.js#planLand; this
|
|
213
|
+
// action only declares intent so the harness/log can see it.
|
|
214
|
+
helper: 'lib/orchestrator/land.js',
|
|
215
|
+
};
|
|
216
|
+
case STATES.EPIC_BOUNDARY_CHECK:
|
|
217
|
+
return {
|
|
218
|
+
type: 'noop',
|
|
219
|
+
phase: state.phase,
|
|
220
|
+
reason: 'epic_boundary_check',
|
|
221
|
+
};
|
|
222
|
+
case STATES.RETROSPECTIVE: {
|
|
223
|
+
if (profile.retrospective_mode === 'stop') {
|
|
224
|
+
return {
|
|
225
|
+
type: 'user_prompt',
|
|
226
|
+
phase: state.phase,
|
|
227
|
+
reason: 'retrospective_mode_stop',
|
|
228
|
+
prompt:
|
|
229
|
+
'Retrospective requested in `stop` mode. Run `/bmad-retrospective` interactively, then resume autopilot.',
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
type: 'invoke_skill',
|
|
234
|
+
skill: 'bmad-retrospective',
|
|
235
|
+
phase: state.phase,
|
|
236
|
+
template: 'bmad-retrospective.tmpl.md',
|
|
237
|
+
template_slots: buildTemplateSlots(state, profile, {
|
|
238
|
+
epic_key: state.current_epic || null,
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
case STATES.SPRINT_FINALIZE_PENDING:
|
|
243
|
+
return {
|
|
244
|
+
type: 'halt',
|
|
245
|
+
phase: state.phase,
|
|
246
|
+
reason: 'sprint_complete',
|
|
247
|
+
handoff: 'sprint_finalize_pending',
|
|
248
|
+
};
|
|
249
|
+
case STATES.NANO_QUICK_DEV:
|
|
250
|
+
return {
|
|
251
|
+
type: 'invoke_skill',
|
|
252
|
+
skill: 'bmad-quick-dev',
|
|
253
|
+
phase: state.phase,
|
|
254
|
+
template: 'bmad-quick-dev.tmpl.md',
|
|
255
|
+
template_slots: buildTemplateSlots(state, profile),
|
|
256
|
+
};
|
|
257
|
+
default:
|
|
258
|
+
throw new Error(`nextAction: unknown phase ${state.phase}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// nextStateAfterSuccess(currentState, profile, signal) → newPhase
|
|
263
|
+
// Encodes the conditional edges (code_review→patch_apply only when any
|
|
264
|
+
// finding.action==='patch'; patch_retest→code_review when blocking remains;
|
|
265
|
+
// epic_boundary_check → retrospective when end of epic; etc.)
|
|
266
|
+
//
|
|
267
|
+
// Returns the next phase string, or null when the LLM should pause (the
|
|
268
|
+
// orchestrator will look at the previous action's success.output for hints).
|
|
269
|
+
function nextStateAfterSuccess(currentState, profile, signal) {
|
|
270
|
+
if (!currentState || !currentState.phase) throw new Error('nextStateAfterSuccess: phase required');
|
|
271
|
+
const phase = currentState.phase;
|
|
272
|
+
const output = (signal && signal.output) || {};
|
|
273
|
+
|
|
274
|
+
// First: hint tiebreaker. If the LLM provided a structurally-valid hint, prefer it.
|
|
275
|
+
const successors = (profile.implementation_flow === 'quick' ? NANO_FLOW_SUCCESSORS : FULL_FLOW_SUCCESSORS)[phase] || [];
|
|
276
|
+
const hint = signal && signal.next_skill_hint;
|
|
277
|
+
// We only consult the hint when the deterministic decision below has more
|
|
278
|
+
// than one valid successor. Compute the deterministic answer first.
|
|
279
|
+
|
|
280
|
+
const det = deterministicNext(currentState, profile, output);
|
|
281
|
+
if (det && successors.length > 1 && hint && hintMatchesPhase(hint, det.allValid)) {
|
|
282
|
+
const chosen = mapHintToPhase(hint, det.allValid);
|
|
283
|
+
if (chosen) return chosen;
|
|
284
|
+
}
|
|
285
|
+
return det ? det.chosen : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function deterministicNext(state, profile, output) {
|
|
289
|
+
const phase = state.phase;
|
|
290
|
+
switch (phase) {
|
|
291
|
+
case STATES.CREATE_STORY:
|
|
292
|
+
return { chosen: STATES.CHECK_READINESS, allValid: [STATES.CHECK_READINESS] };
|
|
293
|
+
case STATES.CHECK_READINESS:
|
|
294
|
+
return { chosen: STATES.DEV_RED, allValid: [STATES.DEV_RED] };
|
|
295
|
+
case STATES.DEV_RED:
|
|
296
|
+
return { chosen: STATES.DEV_GREEN, allValid: [STATES.DEV_GREEN] };
|
|
297
|
+
case STATES.DEV_GREEN:
|
|
298
|
+
return { chosen: STATES.CODE_REVIEW, allValid: [STATES.CODE_REVIEW] };
|
|
299
|
+
case STATES.CODE_REVIEW: {
|
|
300
|
+
const findings = Array.isArray(output.findings) ? output.findings : [];
|
|
301
|
+
const hasPatch = findings.some((f) => f && f.action === 'patch');
|
|
302
|
+
const hasBlock = findings.some((f) => f && f.action === 'block');
|
|
303
|
+
// `block` findings produce a user_prompt via the adapt layer — not a
|
|
304
|
+
// forward state transition. Here we just choose the structural successor.
|
|
305
|
+
if (hasBlock) return null;
|
|
306
|
+
const chosen = hasPatch ? STATES.PATCH_APPLY : STATES.STORY_DONE;
|
|
307
|
+
return { chosen, allValid: [STATES.PATCH_APPLY, STATES.STORY_DONE] };
|
|
308
|
+
}
|
|
309
|
+
case STATES.PATCH_APPLY:
|
|
310
|
+
return { chosen: STATES.PATCH_RETEST, allValid: [STATES.PATCH_RETEST] };
|
|
311
|
+
case STATES.PATCH_RETEST: {
|
|
312
|
+
const findings = Array.isArray(output.remaining_findings) ? output.remaining_findings : [];
|
|
313
|
+
const stillBlocking = findings.some((f) => f && f.action === 'block');
|
|
314
|
+
const chosen = stillBlocking ? STATES.CODE_REVIEW : STATES.STORY_DONE;
|
|
315
|
+
return { chosen, allValid: [STATES.CODE_REVIEW, STATES.STORY_DONE] };
|
|
316
|
+
}
|
|
317
|
+
case STATES.STORY_DONE: {
|
|
318
|
+
// Land-as-you-go: route through STORY_LAND before EPIC_BOUNDARY_CHECK.
|
|
319
|
+
// The default 'stacked' strategy skips STORY_LAND entirely.
|
|
320
|
+
const goLand =
|
|
321
|
+
profile && profile.merge_strategy === 'land_as_you_go'
|
|
322
|
+
? STATES.STORY_LAND
|
|
323
|
+
: STATES.EPIC_BOUNDARY_CHECK;
|
|
324
|
+
return { chosen: goLand, allValid: [STATES.STORY_LAND, STATES.EPIC_BOUNDARY_CHECK] };
|
|
325
|
+
}
|
|
326
|
+
case STATES.STORY_LAND:
|
|
327
|
+
return { chosen: STATES.EPIC_BOUNDARY_CHECK, allValid: [STATES.EPIC_BOUNDARY_CHECK] };
|
|
328
|
+
case STATES.EPIC_BOUNDARY_CHECK: {
|
|
329
|
+
const remainingInEpic = state.remaining_stories_in_epic || 0;
|
|
330
|
+
const sprintDone = !!state.sprint_is_complete;
|
|
331
|
+
// End of epic?
|
|
332
|
+
if (remainingInEpic <= 0) {
|
|
333
|
+
if (profile.retrospective_mode === 'skip') {
|
|
334
|
+
return {
|
|
335
|
+
chosen: sprintDone ? STATES.SPRINT_FINALIZE_PENDING : nextStoryStart(profile),
|
|
336
|
+
allValid: [STATES.SPRINT_FINALIZE_PENDING, nextStoryStart(profile)],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { chosen: STATES.RETROSPECTIVE, allValid: [STATES.RETROSPECTIVE] };
|
|
340
|
+
}
|
|
341
|
+
// More stories in the same epic.
|
|
342
|
+
return { chosen: nextStoryStart(profile), allValid: [nextStoryStart(profile)] };
|
|
343
|
+
}
|
|
344
|
+
case STATES.RETROSPECTIVE: {
|
|
345
|
+
const sprintDone = !!state.sprint_is_complete;
|
|
346
|
+
const chosen = sprintDone ? STATES.SPRINT_FINALIZE_PENDING : nextStoryStart(profile);
|
|
347
|
+
return { chosen, allValid: [STATES.SPRINT_FINALIZE_PENDING, nextStoryStart(profile)] };
|
|
348
|
+
}
|
|
349
|
+
case STATES.NANO_QUICK_DEV:
|
|
350
|
+
return { chosen: STATES.STORY_DONE, allValid: [STATES.STORY_DONE] };
|
|
351
|
+
case STATES.SPRINT_FINALIZE_PENDING:
|
|
352
|
+
return null;
|
|
353
|
+
default:
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function nextStoryStart(profile) {
|
|
359
|
+
return profile.implementation_flow === 'quick' ? STATES.NANO_QUICK_DEV : STATES.CREATE_STORY;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Best-effort mapping from a next_skill_hint string (e.g. "bmad-code-review")
|
|
363
|
+
// to a phase identifier. Used only as a tiebreaker.
|
|
364
|
+
const HINT_TO_PHASE = {
|
|
365
|
+
'bmad-create-story': STATES.CREATE_STORY,
|
|
366
|
+
'bmad-check-implementation-readiness': STATES.CHECK_READINESS,
|
|
367
|
+
'bmad-dev-story:red': STATES.DEV_RED,
|
|
368
|
+
'bmad-dev-story:green': STATES.DEV_GREEN,
|
|
369
|
+
'bmad-dev-story:patch': STATES.PATCH_APPLY,
|
|
370
|
+
'bmad-dev-story:rereview': STATES.PATCH_RETEST,
|
|
371
|
+
'bmad-code-review': STATES.CODE_REVIEW,
|
|
372
|
+
'bmad-retrospective': STATES.RETROSPECTIVE,
|
|
373
|
+
'bmad-quick-dev': STATES.NANO_QUICK_DEV,
|
|
374
|
+
story_done: STATES.STORY_DONE,
|
|
375
|
+
sprint_finalize_pending: STATES.SPRINT_FINALIZE_PENDING,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
function mapHintToPhase(hint, allValidPhases) {
|
|
379
|
+
if (typeof hint !== 'string') return null;
|
|
380
|
+
const phase = HINT_TO_PHASE[hint];
|
|
381
|
+
if (!phase) return null;
|
|
382
|
+
if (!allValidPhases.includes(phase)) return null;
|
|
383
|
+
return phase;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function hintMatchesPhase(hint, allValidPhases) {
|
|
387
|
+
return mapHintToPhase(hint, allValidPhases) !== null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
STATES,
|
|
392
|
+
TERMINAL_STATES,
|
|
393
|
+
FULL_FLOW_SUCCESSORS,
|
|
394
|
+
NANO_FLOW_SUCCESSORS,
|
|
395
|
+
nextAction,
|
|
396
|
+
nextStateAfterSuccess,
|
|
397
|
+
// Exposed for adapt.js to construct fresh-story states.
|
|
398
|
+
nextStoryStart,
|
|
399
|
+
// Exposed for tests / inspection.
|
|
400
|
+
buildTemplateSlots,
|
|
401
|
+
HINT_TO_PHASE,
|
|
402
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// state-store.js — single chokepoint for autopilot state writes.
|
|
2
|
+
//
|
|
3
|
+
// Honors `coalesce_state_writes` from the active profile:
|
|
4
|
+
//
|
|
5
|
+
// coalesce_state_writes: false (legacy v1.0.5 path)
|
|
6
|
+
// → every write goes straight to autopilot-state.yaml
|
|
7
|
+
//
|
|
8
|
+
// coalesce_state_writes: true (M3 / PR 6 path)
|
|
9
|
+
// → CRITICAL_KEYS write straight through (crash-recovery semantics)
|
|
10
|
+
// → non-critical fields accumulate in a pending buffer per story
|
|
11
|
+
// → buffer is flushed at story boundary + session checkpoint
|
|
12
|
+
//
|
|
13
|
+
// CRITICAL_KEYS mirror state-shard.js so the two implementations stay
|
|
14
|
+
// semantically aligned. Tests cover both code paths.
|
|
15
|
+
//
|
|
16
|
+
// All I/O goes through an injected `fs` so tests use tmp dirs and the
|
|
17
|
+
// orchestrator can wire in alternative stores later.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const nodeFs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
// Mirrors _Sprintpilot/scripts/state-shard.js#CRITICAL_KEYS.
|
|
25
|
+
const CRITICAL_KEYS = new Set([
|
|
26
|
+
'current_story',
|
|
27
|
+
'current_bmad_step',
|
|
28
|
+
'in_worktree',
|
|
29
|
+
'patch_commits',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// In-memory pending buffer. Process-scoped — flushed at story boundary or
|
|
33
|
+
// session checkpoint. Keyed by story.
|
|
34
|
+
const _pendingBuffers = new Map();
|
|
35
|
+
|
|
36
|
+
function isPlainObject(v) {
|
|
37
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deepMerge(target, source) {
|
|
41
|
+
if (!isPlainObject(source)) return source;
|
|
42
|
+
const out = isPlainObject(target) ? { ...target } : {};
|
|
43
|
+
for (const key of Object.keys(source)) {
|
|
44
|
+
const sv = source[key];
|
|
45
|
+
const tv = out[key];
|
|
46
|
+
if (isPlainObject(sv) && isPlainObject(tv)) out[key] = deepMerge(tv, sv);
|
|
47
|
+
else out[key] = sv;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dumpYaml(obj, indent = 0) {
|
|
53
|
+
const pad = ' '.repeat(indent);
|
|
54
|
+
const lines = [];
|
|
55
|
+
for (const key of Object.keys(obj)) {
|
|
56
|
+
const val = obj[key];
|
|
57
|
+
if (val === null || val === undefined) lines.push(`${pad}${key}: null`);
|
|
58
|
+
else if (Array.isArray(val)) lines.push(`${pad}${key}: ${JSON.stringify(val)}`);
|
|
59
|
+
else if (isPlainObject(val)) {
|
|
60
|
+
lines.push(`${pad}${key}:`);
|
|
61
|
+
const inner = dumpYaml(val, indent + 1);
|
|
62
|
+
if (inner) lines.push(inner);
|
|
63
|
+
} else if (typeof val === 'boolean' || typeof val === 'number') {
|
|
64
|
+
lines.push(`${pad}${key}: ${val}`);
|
|
65
|
+
} else {
|
|
66
|
+
const s = String(val);
|
|
67
|
+
const needsQuote = /^(true|false|null|~)$/i.test(s) || /^-?\d/.test(s) || /[:#]/.test(s);
|
|
68
|
+
lines.push(`${pad}${key}: ${needsQuote ? JSON.stringify(s) : s}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Atomic write via tmp sibling + rename.
|
|
75
|
+
function atomicWrite(fs, filePath, text) {
|
|
76
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
77
|
+
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
78
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
79
|
+
fs.renameSync(tmp, filePath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readStateFile(fs, filePath) {
|
|
83
|
+
try {
|
|
84
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
85
|
+
return parseYamlNarrow(text);
|
|
86
|
+
} catch (_e) {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Narrow YAML parser sufficient for our write shape (the same shape we
|
|
92
|
+
// produce via dumpYaml above). We deliberately avoid js-yaml so we don't
|
|
93
|
+
// pull a runtime dep into the install-time script bundle.
|
|
94
|
+
function parseYamlNarrow(text) {
|
|
95
|
+
if (!text) return {};
|
|
96
|
+
const lines = text.split(/\r?\n/);
|
|
97
|
+
const root = {};
|
|
98
|
+
const stack = [{ indent: -1, obj: root }];
|
|
99
|
+
for (const raw of lines) {
|
|
100
|
+
const hashIdx = raw.indexOf('#');
|
|
101
|
+
const line = hashIdx === -1 ? raw : raw.slice(0, hashIdx);
|
|
102
|
+
if (!line.trim()) continue;
|
|
103
|
+
const indent = line.match(/^( *)/)[1].length;
|
|
104
|
+
const content = line.slice(indent).trimEnd();
|
|
105
|
+
const colon = content.indexOf(':');
|
|
106
|
+
if (colon === -1) continue;
|
|
107
|
+
const key = content.slice(0, colon).trim();
|
|
108
|
+
const rest = content.slice(colon + 1).trim();
|
|
109
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
|
|
110
|
+
const parent = stack[stack.length - 1].obj;
|
|
111
|
+
if (rest === '') {
|
|
112
|
+
const child = {};
|
|
113
|
+
parent[key] = child;
|
|
114
|
+
stack.push({ indent, obj: child });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
parent[key] = parseScalar(rest);
|
|
118
|
+
}
|
|
119
|
+
return root;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseScalar(raw) {
|
|
123
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(raw);
|
|
126
|
+
} catch (_e) {
|
|
127
|
+
return raw.slice(1, -1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (raw.startsWith('[') || raw.startsWith('{')) {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(raw);
|
|
133
|
+
} catch (_e) {
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (raw === 'null' || raw === '~') return null;
|
|
138
|
+
if (raw === 'true') return true;
|
|
139
|
+
if (raw === 'false') return false;
|
|
140
|
+
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
141
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
142
|
+
return raw;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveStatePath(projectRoot) {
|
|
146
|
+
return path.join(
|
|
147
|
+
projectRoot,
|
|
148
|
+
'_bmad-output',
|
|
149
|
+
'implementation-artifacts',
|
|
150
|
+
'autopilot-state.yaml',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function bufferKey(story) {
|
|
155
|
+
return story || 'sprint';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// write(updates, profile, context)
|
|
159
|
+
// updates: object of key→value updates (deep-merged into the file)
|
|
160
|
+
// profile: typed Profile (only `coalesce_state_writes` is consulted here)
|
|
161
|
+
// context: { projectRoot, story, fs? }
|
|
162
|
+
// Returns { mode: 'direct' | 'critical' | 'pending', flushed: boolean }
|
|
163
|
+
function write(updates, profile, context) {
|
|
164
|
+
if (!isPlainObject(updates)) throw new Error('write: updates must be an object');
|
|
165
|
+
if (!profile) throw new Error('write: profile required');
|
|
166
|
+
if (!context || !context.projectRoot) throw new Error('write: context.projectRoot required');
|
|
167
|
+
const fs = (context && context.fs) || nodeFs;
|
|
168
|
+
const story = context.story;
|
|
169
|
+
|
|
170
|
+
// Legacy path: direct write.
|
|
171
|
+
if (!profile.coalesce_state_writes) {
|
|
172
|
+
return writeDirect(fs, context.projectRoot, updates);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Coalesce path. Split critical vs non-critical.
|
|
176
|
+
const critical = {};
|
|
177
|
+
const nonCritical = {};
|
|
178
|
+
let hasCritical = false;
|
|
179
|
+
let hasNonCritical = false;
|
|
180
|
+
for (const k of Object.keys(updates)) {
|
|
181
|
+
if (CRITICAL_KEYS.has(k)) {
|
|
182
|
+
critical[k] = updates[k];
|
|
183
|
+
hasCritical = true;
|
|
184
|
+
} else {
|
|
185
|
+
nonCritical[k] = updates[k];
|
|
186
|
+
hasNonCritical = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// If we have critical writes, flush pending first then write critical + accumulated.
|
|
191
|
+
if (hasCritical) {
|
|
192
|
+
const key = bufferKey(story);
|
|
193
|
+
const buf = _pendingBuffers.get(key) || {};
|
|
194
|
+
const merged = { ...buf, ...nonCritical, ...critical };
|
|
195
|
+
_pendingBuffers.delete(key);
|
|
196
|
+
writeDirect(fs, context.projectRoot, merged);
|
|
197
|
+
return { mode: 'critical', flushed: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Non-critical only → buffer.
|
|
201
|
+
if (hasNonCritical) {
|
|
202
|
+
const key = bufferKey(story);
|
|
203
|
+
const buf = _pendingBuffers.get(key) || {};
|
|
204
|
+
_pendingBuffers.set(key, deepMerge(buf, nonCritical));
|
|
205
|
+
return { mode: 'pending', flushed: false };
|
|
206
|
+
}
|
|
207
|
+
return { mode: 'noop', flushed: false };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// writeDirect — write to autopilot-state.yaml directly, merging on top.
|
|
211
|
+
function writeDirect(fs, projectRoot, updates) {
|
|
212
|
+
const filePath = resolveStatePath(projectRoot);
|
|
213
|
+
const existing = readStateFile(fs, filePath);
|
|
214
|
+
const merged = deepMerge(existing, updates);
|
|
215
|
+
merged.last_updated = new Date().toISOString();
|
|
216
|
+
atomicWrite(fs, filePath, `${dumpYaml(merged)}\n`);
|
|
217
|
+
return { mode: 'direct', flushed: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// flush(profile, context) — flush pending buffer for the given story.
|
|
221
|
+
// Called at story boundary and session checkpoint.
|
|
222
|
+
function flush(profile, context) {
|
|
223
|
+
if (!profile || !profile.coalesce_state_writes) return { flushed: false, mode: 'noop' };
|
|
224
|
+
if (!context || !context.projectRoot) throw new Error('flush: context.projectRoot required');
|
|
225
|
+
const fs = (context && context.fs) || nodeFs;
|
|
226
|
+
const key = bufferKey(context.story);
|
|
227
|
+
const buf = _pendingBuffers.get(key);
|
|
228
|
+
if (!buf || Object.keys(buf).length === 0) return { flushed: false, mode: 'noop' };
|
|
229
|
+
_pendingBuffers.delete(key);
|
|
230
|
+
writeDirect(fs, context.projectRoot, buf);
|
|
231
|
+
return { flushed: true, mode: 'flush' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// read(context) — read full state object from disk.
|
|
235
|
+
function read(context) {
|
|
236
|
+
if (!context || !context.projectRoot) throw new Error('read: context.projectRoot required');
|
|
237
|
+
const fs = (context && context.fs) || nodeFs;
|
|
238
|
+
return readStateFile(fs, resolveStatePath(context.projectRoot));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// peekPending(story) — for tests + debugging.
|
|
242
|
+
function peekPending(story) {
|
|
243
|
+
const buf = _pendingBuffers.get(bufferKey(story));
|
|
244
|
+
return buf ? { ...buf } : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// resetPending() — clear all buffers; tests only.
|
|
248
|
+
function resetPending() {
|
|
249
|
+
_pendingBuffers.clear();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
CRITICAL_KEYS: Array.from(CRITICAL_KEYS),
|
|
254
|
+
write,
|
|
255
|
+
flush,
|
|
256
|
+
read,
|
|
257
|
+
peekPending,
|
|
258
|
+
resetPending,
|
|
259
|
+
resolveStatePath,
|
|
260
|
+
};
|