@ikunin/sprintpilot 2.0.9 → 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.
Files changed (52) hide show
  1. package/README.md +245 -10
  2. package/_Sprintpilot/Sprintpilot.md +1 -1
  3. package/_Sprintpilot/bin/autopilot.js +581 -0
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
  6. package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
  7. package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
  8. package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
  9. package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
  10. package/_Sprintpilot/lib/orchestrator/land.js +155 -0
  11. package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
  12. package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
  13. package/_Sprintpilot/lib/orchestrator/report.js +95 -0
  14. package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
  15. package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
  16. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
  17. package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
  18. package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
  19. package/_Sprintpilot/manifest.yaml +1 -1
  20. package/_Sprintpilot/modules/git/config.yaml +26 -0
  21. package/_Sprintpilot/scripts/agent-adapter.js +4 -5
  22. package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
  23. package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
  24. package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
  25. package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
  26. package/_Sprintpilot/scripts/land-this-pr.js +110 -0
  27. package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
  28. package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
  29. package/_Sprintpilot/scripts/log-timing.js +12 -3
  30. package/_Sprintpilot/scripts/merge-shards.js +32 -12
  31. package/_Sprintpilot/scripts/post-green-gates.js +187 -0
  32. package/_Sprintpilot/scripts/preflight-merge.js +2 -1
  33. package/_Sprintpilot/scripts/resolve-dag.js +3 -1
  34. package/_Sprintpilot/scripts/scan.js +109 -13
  35. package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
  36. package/_Sprintpilot/scripts/state-shard.js +8 -1
  37. package/_Sprintpilot/scripts/summarize-timings.js +30 -12
  38. package/_Sprintpilot/scripts/with-retry.js +17 -5
  39. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
  40. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
  41. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +9 -0
  42. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +9 -0
  43. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +9 -0
  44. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +9 -0
  45. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +10 -0
  46. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +2 -0
  47. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +7 -0
  48. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +7 -0
  49. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +7 -0
  50. package/lib/core/update-check.js +11 -1
  51. package/package.json +1 -1
  52. 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
+ };