@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,148 @@
1
+ // action-ledger.js — append-only JSONL ledger of orchestrator activity.
2
+ //
3
+ // Each line is a JSON object describing one event in the orchestrator's
4
+ // life: emitted action, recorded signal, side-effect, profile escalation,
5
+ // verify rejection, etc. The ledger is the single source of truth for
6
+ // resume detection (see divergence.js) and post-hoc audit.
7
+ //
8
+ // File layout: <projectRoot>/_bmad-output/implementation-artifacts/
9
+ // ledger.jsonl
10
+ //
11
+ // Append-only by contract — no in-place edits. Atomic via fs.appendFileSync.
12
+ // JSON-per-line so partial writes are recoverable (a corrupt tail line can
13
+ // be skipped by the reader; previous lines remain valid).
14
+
15
+ 'use strict';
16
+
17
+ const nodeFs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const LEDGER_FILENAME = 'ledger.jsonl';
21
+
22
+ const VALID_KINDS = [
23
+ 'action_emitted',
24
+ 'signal_recorded',
25
+ 'verify_result',
26
+ 'state_transition',
27
+ 'profile_escalated',
28
+ 'decisions_appended',
29
+ 'user_commands_applied',
30
+ 'alternative_proposed',
31
+ 'verify_override',
32
+ 'verify_rejected',
33
+ 'halt',
34
+ 'resume',
35
+ 'lock_acquired',
36
+ 'lock_released',
37
+ 'flush',
38
+ ];
39
+
40
+ function isPlainObject(v) {
41
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
42
+ }
43
+
44
+ function resolveLedgerPath(projectRoot) {
45
+ return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', LEDGER_FILENAME);
46
+ }
47
+
48
+ // append(entry, context)
49
+ // entry: {
50
+ // kind: one of VALID_KINDS,
51
+ // ...kind-specific fields
52
+ // }
53
+ // context: { projectRoot, now?: () => Date, fs? }
54
+ //
55
+ // Returns the persisted entry with `ts` (ISO timestamp) and `seq` populated.
56
+ function append(entry, context) {
57
+ if (!isPlainObject(entry)) throw new Error('append: entry must be an object');
58
+ if (!entry.kind || !VALID_KINDS.includes(entry.kind)) {
59
+ throw new Error(`append: entry.kind must be one of ${VALID_KINDS.join(',')}`);
60
+ }
61
+ if (!context || !context.projectRoot) throw new Error('append: context.projectRoot required');
62
+ const fs = (context && context.fs) || nodeFs;
63
+ const nowFn = (context && context.now) || (() => new Date());
64
+
65
+ const filePath = resolveLedgerPath(context.projectRoot);
66
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
67
+
68
+ const seq = nextSeq(fs, filePath);
69
+ const stamped = {
70
+ seq,
71
+ ts: nowFn().toISOString(),
72
+ ...entry,
73
+ };
74
+ fs.appendFileSync(filePath, `${JSON.stringify(stamped)}\n`, 'utf8');
75
+ return stamped;
76
+ }
77
+
78
+ // read(context, options?) — read the full ledger, skipping corrupt tail lines.
79
+ // options.limit: number — return only the last N entries
80
+ function read(context, options) {
81
+ if (!context || !context.projectRoot) throw new Error('read: context.projectRoot required');
82
+ const fs = (context && context.fs) || nodeFs;
83
+ const filePath = resolveLedgerPath(context.projectRoot);
84
+
85
+ let text = '';
86
+ try {
87
+ text = fs.readFileSync(filePath, 'utf8');
88
+ } catch (_e) {
89
+ return [];
90
+ }
91
+ const lines = text.split(/\n/).filter((l) => l.length > 0);
92
+ const entries = [];
93
+ for (const line of lines) {
94
+ try {
95
+ const obj = JSON.parse(line);
96
+ if (isPlainObject(obj)) entries.push(obj);
97
+ } catch (_e) {
98
+ // Skip corrupt line (likely partial write). Read continues — append-only
99
+ // semantics mean prior lines are still trustworthy.
100
+ }
101
+ }
102
+ if (options && typeof options.limit === 'number' && options.limit > 0) {
103
+ return entries.slice(-options.limit);
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ // last(context, kind?) — return the most recent entry, optionally filtered by kind.
109
+ function last(context, kind) {
110
+ const entries = read(context);
111
+ if (!entries.length) return null;
112
+ if (!kind) return entries[entries.length - 1];
113
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
114
+ if (entries[i].kind === kind) return entries[i];
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // nextSeq — compute the next sequence number by inspecting the last line.
120
+ // Reading just the tail is cheap because we use append-only JSONL.
121
+ function nextSeq(fs, filePath) {
122
+ let text = '';
123
+ try {
124
+ text = fs.readFileSync(filePath, 'utf8');
125
+ } catch (_e) {
126
+ return 1;
127
+ }
128
+ if (!text) return 1;
129
+ const lines = text.split(/\n/).filter((l) => l.length > 0);
130
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
131
+ try {
132
+ const obj = JSON.parse(lines[i]);
133
+ if (typeof obj.seq === 'number') return obj.seq + 1;
134
+ } catch (_e) {
135
+ // Try the previous line.
136
+ }
137
+ }
138
+ return 1;
139
+ }
140
+
141
+ module.exports = {
142
+ VALID_KINDS,
143
+ LEDGER_FILENAME,
144
+ append,
145
+ read,
146
+ last,
147
+ resolveLedgerPath,
148
+ };
@@ -0,0 +1,502 @@
1
+ // adapt.js — pure adaptation function.
2
+ // (state, signal, profile, world) → { newState, nextAction, sideEffects }
3
+ //
4
+ // Translates a Signal from the LLM into the orchestrator's next state plus
5
+ // any side effects (decision-log appends, profile escalations, blocker
6
+ // counting, retry-budget bookkeeping).
7
+ //
8
+ // Pure module. No I/O. Side effects are returned as data; the CLI edge
9
+ // performs them.
10
+
11
+ 'use strict';
12
+
13
+ const { STATES, nextAction, nextStateAfterSuccess, nextStoryStart } = require('./state-machine');
14
+ const { classifyImpact } = require('./impact-classifier');
15
+ const { escalateOnFailure } = require('./profile-rules');
16
+
17
+ // Threshold for `consecutive_test_failures` — workflow.md:81 says 3.
18
+ const CONSECUTIVE_TEST_FAILURE_THRESHOLD = 3;
19
+
20
+ // Valid signal statuses.
21
+ const SIGNAL_STATUSES = [
22
+ 'success',
23
+ 'failure',
24
+ 'blocked',
25
+ 'propose_alternative',
26
+ 'user_input',
27
+ 'verify_override',
28
+ ];
29
+
30
+ // Pure: given current orchestrator state + the incoming signal, return:
31
+ // {
32
+ // newState, // updated runtime state shape
33
+ // newProfile, // possibly escalated profile (same reference if unchanged)
34
+ // nextAction, // canonical action for the new state, or a user_prompt / halt
35
+ // sideEffects, // ordered list of effects: { kind: 'append_decisions' | ... }
36
+ // verdict, // one of: 'advanced' | 'retry' | 'prompted' | 'halt'
37
+ // }
38
+ function interpretSignal(state, signal, profile, verifyResult) {
39
+ if (!signal || !signal.status) {
40
+ throw new Error('interpretSignal: signal.status required');
41
+ }
42
+ if (!SIGNAL_STATUSES.includes(signal.status)) {
43
+ throw new Error(`interpretSignal: unknown signal status ${signal.status}`);
44
+ }
45
+
46
+ const sideEffects = [];
47
+
48
+ // Decision-log append always runs first (across every signal status).
49
+ if (Array.isArray(signal.decisions) && signal.decisions.length > 0) {
50
+ sideEffects.push({ kind: 'append_decisions', decisions: signal.decisions, phase: state.phase });
51
+ }
52
+
53
+ switch (signal.status) {
54
+ case 'success':
55
+ return handleSuccess(state, signal, profile, verifyResult, sideEffects);
56
+ case 'failure':
57
+ return handleFailure(state, signal, profile, sideEffects);
58
+ case 'blocked':
59
+ return handleBlocked(state, signal, profile, sideEffects);
60
+ case 'propose_alternative':
61
+ return handleProposeAlternative(state, signal, profile, sideEffects);
62
+ case 'user_input':
63
+ return handleUserInput(state, signal, profile, sideEffects);
64
+ case 'verify_override':
65
+ return handleVerifyOverride(state, signal, profile, verifyResult, sideEffects);
66
+ default:
67
+ throw new Error(`interpretSignal: unhandled status ${signal.status}`);
68
+ }
69
+ }
70
+
71
+ function handleSuccess(state, signal, profile, verifyResult, sideEffects) {
72
+ // Trust boundary: verify.js may reject what the LLM claims as success.
73
+ if (verifyResult && verifyResult.ok === false) {
74
+ const rejectCount = (state.verify_reject_count || 0) + 1;
75
+ sideEffects.push({
76
+ kind: 'log_verify_rejection',
77
+ phase: state.phase,
78
+ issues: verifyResult.issues || [],
79
+ consecutive: rejectCount,
80
+ });
81
+ if (rejectCount >= profile.verify_reject_budget) {
82
+ return {
83
+ newState: { ...state, verify_reject_count: 0 },
84
+ newProfile: profile,
85
+ nextAction: {
86
+ type: 'user_prompt',
87
+ phase: state.phase,
88
+ reason: 'verify_reject_budget_exceeded',
89
+ prompt: `verify.js rejected ${rejectCount} consecutive success signals on ${state.phase}. Last issues: ${JSON.stringify(verifyResult.issues || [])}`,
90
+ },
91
+ sideEffects,
92
+ verdict: 'prompted',
93
+ };
94
+ }
95
+ return {
96
+ newState: { ...state, verify_reject_count: rejectCount },
97
+ newProfile: profile,
98
+ // Retry the same phase. adapt's caller will re-run nextAction(state, profile).
99
+ nextAction: nextAction(state, profile),
100
+ sideEffects,
101
+ verdict: 'retry',
102
+ };
103
+ }
104
+
105
+ // Verify passed (or wasn't provided). For nano: check escalation triggers.
106
+ let workingProfile = profile;
107
+ if (state.phase === STATES.NANO_QUICK_DEV) {
108
+ const escalated = escalateOnFailure(profile, signal.output);
109
+ if (escalated !== profile) {
110
+ workingProfile = escalated;
111
+ sideEffects.push({
112
+ kind: 'profile_escalated',
113
+ from: 'nano',
114
+ to: escalated.name,
115
+ reason: escalated.escalation_reason,
116
+ });
117
+ }
118
+ }
119
+
120
+ // Code-review with `block` findings is a structural success (review ran)
121
+ // but routes to user_prompt — the LLM can't decide blocking unilaterally.
122
+ if (state.phase === STATES.CODE_REVIEW) {
123
+ const findings = (signal.output && signal.output.findings) || [];
124
+ const blockingFindings = findings.filter((f) => f && f.action === 'block');
125
+ if (blockingFindings.length > 0) {
126
+ return {
127
+ newState: { ...state, verify_reject_count: 0 },
128
+ newProfile: workingProfile,
129
+ nextAction: {
130
+ type: 'user_prompt',
131
+ phase: state.phase,
132
+ reason: 'code_review_blocking_findings',
133
+ prompt: `Code review identified ${blockingFindings.length} blocking finding(s). Manual decision required: ${JSON.stringify(blockingFindings.map((f) => ({ id: f.id, rationale: f.rationale })))}`,
134
+ findings: blockingFindings,
135
+ },
136
+ sideEffects,
137
+ verdict: 'prompted',
138
+ };
139
+ }
140
+ }
141
+
142
+ const newPhase = nextStateAfterSuccess(state, workingProfile, signal);
143
+ if (newPhase === null) {
144
+ // Defensive: shouldn't normally happen since blocking-findings case is handled.
145
+ return {
146
+ newState: { ...state, verify_reject_count: 0 },
147
+ newProfile: workingProfile,
148
+ nextAction: {
149
+ type: 'user_prompt',
150
+ phase: state.phase,
151
+ reason: 'no_deterministic_successor',
152
+ prompt: 'State machine has no deterministic successor for this transition.',
153
+ },
154
+ sideEffects,
155
+ verdict: 'prompted',
156
+ };
157
+ }
158
+
159
+ // Build the new state: carry forward story-scoped fields; reset retry counters.
160
+ const newState = advanceState(state, workingProfile, newPhase, signal);
161
+ return {
162
+ newState,
163
+ newProfile: workingProfile,
164
+ nextAction: nextAction(newState, workingProfile),
165
+ sideEffects,
166
+ verdict: newPhase === STATES.SPRINT_FINALIZE_PENDING ? 'halt' : 'advanced',
167
+ };
168
+ }
169
+
170
+ function handleFailure(state, signal, profile, sideEffects) {
171
+ const recoverable = signal.recoverable !== false;
172
+ const retryCount = (state.retry_count_this_phase || 0) + 1;
173
+ const exhausted = retryCount > profile.retry_budget_per_action;
174
+
175
+ // failure.diagnosis is first-class: persisted into state so the next retry's
176
+ // template gets it via `{{prior_diagnosis}}`.
177
+ const carriedDiagnosis = signal.diagnosis || null;
178
+
179
+ if (!recoverable || exhausted) {
180
+ return {
181
+ newState: {
182
+ ...state,
183
+ retry_count_this_phase: 0,
184
+ prior_diagnosis: carriedDiagnosis,
185
+ },
186
+ newProfile: profile,
187
+ nextAction: {
188
+ type: 'user_prompt',
189
+ phase: state.phase,
190
+ reason: exhausted ? 'retry_budget_exhausted' : 'failure_not_recoverable',
191
+ prompt: signal.reason || 'Action failed; human intervention required.',
192
+ diagnosis: carriedDiagnosis,
193
+ },
194
+ sideEffects,
195
+ verdict: 'prompted',
196
+ };
197
+ }
198
+
199
+ // Recoverable + budget remaining: re-emit the same phase's action with the
200
+ // prior diagnosis threaded into the template slots.
201
+ const newState = {
202
+ ...state,
203
+ retry_count_this_phase: retryCount,
204
+ prior_diagnosis: carriedDiagnosis,
205
+ };
206
+ return {
207
+ newState,
208
+ newProfile: profile,
209
+ nextAction: nextAction(newState, profile),
210
+ sideEffects,
211
+ verdict: 'retry',
212
+ };
213
+ }
214
+
215
+ function handleBlocked(state, signal, profile, sideEffects) {
216
+ const kind = signal.blocker_kind || 'unknown';
217
+
218
+ // Counting blocker: workflow.md:81 says 3 consecutive failures pauses.
219
+ if (kind === 'consecutive_test_failures') {
220
+ const ledgerCount =
221
+ typeof signal.consecutive_count === 'number'
222
+ ? signal.consecutive_count
223
+ : (state.consecutive_test_failures || 0) + 1;
224
+ // Trust but verify — orchestrator tracks independently to detect under-reporting.
225
+ const tracked = Math.max(ledgerCount, (state.consecutive_test_failures || 0) + 1);
226
+ if (tracked >= CONSECUTIVE_TEST_FAILURE_THRESHOLD) {
227
+ return {
228
+ newState: { ...state, consecutive_test_failures: 0 },
229
+ newProfile: profile,
230
+ nextAction: {
231
+ type: 'user_prompt',
232
+ phase: state.phase,
233
+ reason: 'consecutive_test_failures_threshold',
234
+ prompt: `${tracked} consecutive test failures on ${state.phase}. Manual review required.`,
235
+ details: signal.details,
236
+ },
237
+ sideEffects,
238
+ verdict: 'prompted',
239
+ };
240
+ }
241
+ return {
242
+ newState: { ...state, consecutive_test_failures: tracked },
243
+ newProfile: profile,
244
+ nextAction: nextAction(state, profile),
245
+ sideEffects,
246
+ verdict: 'retry',
247
+ };
248
+ }
249
+
250
+ // Other TRUE BLOCKER kinds → always user_prompt regardless of `user_input_needed`.
251
+ const TRUE_BLOCKER_KINDS = new Set([
252
+ 'creative_user_input_required',
253
+ 'new_external_dependency',
254
+ 'security_architectural_decision',
255
+ 'contradictory_acceptance_criteria',
256
+ ]);
257
+ if (TRUE_BLOCKER_KINDS.has(kind) || signal.user_input_needed === true) {
258
+ return {
259
+ newState: state,
260
+ newProfile: profile,
261
+ nextAction: {
262
+ type: 'user_prompt',
263
+ phase: state.phase,
264
+ reason: kind,
265
+ prompt: signal.details || `Blocked: ${kind}`,
266
+ },
267
+ sideEffects,
268
+ verdict: 'prompted',
269
+ };
270
+ }
271
+
272
+ // Recoverable blockers: deterministic recovery per kind (initial set).
273
+ switch (kind) {
274
+ case 'missing_dependency':
275
+ return {
276
+ newState: state,
277
+ newProfile: profile,
278
+ nextAction: {
279
+ type: 'run_script',
280
+ phase: state.phase,
281
+ reason: 'install_missing_dependency',
282
+ command: ['npm', 'install'],
283
+ },
284
+ sideEffects,
285
+ verdict: 'retry',
286
+ };
287
+ case 'external_service':
288
+ // Defer: retry once, then prompt. We use retry_count_this_phase as the budget.
289
+ return handleFailure(state, { reason: signal.details, recoverable: true }, profile, sideEffects);
290
+ case 'failed_invariant':
291
+ case 'unknown':
292
+ default:
293
+ return {
294
+ newState: state,
295
+ newProfile: profile,
296
+ nextAction: {
297
+ type: 'user_prompt',
298
+ phase: state.phase,
299
+ reason: `blocked_${kind}`,
300
+ prompt: signal.details || `Blocked: ${kind}`,
301
+ },
302
+ sideEffects,
303
+ verdict: 'prompted',
304
+ };
305
+ }
306
+ }
307
+
308
+ function handleProposeAlternative(state, signal, profile, sideEffects) {
309
+ const planned = nextAction(state, profile);
310
+ const alternative = signal.alternative;
311
+ const impact = classifyImpact(planned, alternative, signal.urgency_hint);
312
+
313
+ sideEffects.push({
314
+ kind: 'log_alternative_proposed',
315
+ phase: state.phase,
316
+ impact,
317
+ reason: signal.reason,
318
+ });
319
+
320
+ if (impact === 'low') {
321
+ // Auto-accept. The CLI edge swaps the planned action for the alternative.
322
+ return {
323
+ newState: state,
324
+ newProfile: profile,
325
+ nextAction: { ...alternative, _accepted_alternative: true, _impact: impact },
326
+ sideEffects,
327
+ verdict: 'advanced',
328
+ };
329
+ }
330
+
331
+ return {
332
+ newState: state,
333
+ newProfile: profile,
334
+ nextAction: {
335
+ type: 'user_prompt',
336
+ phase: state.phase,
337
+ reason: 'alternative_requires_approval',
338
+ prompt: signal.reason || 'LLM proposed an alternative action.',
339
+ planned,
340
+ alternative,
341
+ impact,
342
+ },
343
+ sideEffects,
344
+ verdict: 'prompted',
345
+ };
346
+ }
347
+
348
+ function handleUserInput(state, signal, profile, sideEffects) {
349
+ // Adapt does not validate commands (that's user-commands.js' job at the CLI
350
+ // edge) but it does decide the structural response: a user_input signal
351
+ // always triggers a re-emission of nextAction under the new state. The
352
+ // CLI edge applies the commands first, then calls adapt with the new state.
353
+ sideEffects.push({
354
+ kind: 'apply_user_commands',
355
+ commands: signal.commands || [],
356
+ phase: state.phase,
357
+ });
358
+ return {
359
+ newState: state,
360
+ newProfile: profile,
361
+ nextAction: nextAction(state, profile),
362
+ sideEffects,
363
+ verdict: 'advanced',
364
+ };
365
+ }
366
+
367
+ function handleVerifyOverride(state, signal, profile, verifyResult, sideEffects) {
368
+ // The LLM contends verify.js' expectations are stale. The CLI edge will
369
+ // re-run verify with the augmented expectations from signal.evidence; the
370
+ // adapt layer just records the override attempt and decides what to do
371
+ // based on the augmented verifyResult passed in.
372
+ sideEffects.push({
373
+ kind: 'log_verify_override',
374
+ phase: state.phase,
375
+ evidence: signal.evidence || null,
376
+ accepted: verifyResult && verifyResult.ok === true,
377
+ });
378
+
379
+ if (verifyResult && verifyResult.ok === true) {
380
+ // Override accepted — treat as success. Synthesize a minimal success signal.
381
+ return handleSuccess(state, { status: 'success', output: signal.evidence }, profile, verifyResult, sideEffects);
382
+ }
383
+
384
+ // Override rejected — fall back to failure(recoverable=true).
385
+ return handleFailure(
386
+ state,
387
+ {
388
+ status: 'failure',
389
+ reason: 'verify_override_rejected',
390
+ diagnosis: 'augmented verify.js still failed',
391
+ recoverable: true,
392
+ },
393
+ profile,
394
+ sideEffects,
395
+ );
396
+ }
397
+
398
+ // advanceState — produce the new runtime state when moving to `newPhase`.
399
+ // Resets phase-scoped counters; clears prior_diagnosis when advancing forward;
400
+ // clears patch_findings when leaving step 6; resets per-story counters when
401
+ // starting a new story.
402
+ function advanceState(state, profile, newPhase, signal) {
403
+ const next = { ...state, phase: newPhase, retry_count_this_phase: 0, verify_reject_count: 0 };
404
+ // Advancing forward clears the prior diagnosis (the LLM resolved it).
405
+ next.prior_diagnosis = null;
406
+
407
+ // Starting a new story resets story-scoped fields. The orchestrator's CLI
408
+ // edge will fill in story_key / story_file_path / current_epic from
409
+ // sprint-status when entering CREATE_STORY or NANO_QUICK_DEV.
410
+ if (newPhase === STATES.CREATE_STORY || newPhase === STATES.NANO_QUICK_DEV) {
411
+ next.consecutive_test_failures = 0;
412
+ next.patch_findings = null;
413
+ next.tests_to_rerun = null;
414
+ }
415
+
416
+ // Leaving step 6 (PATCH_RETEST → STORY_DONE / CODE_REVIEW) clears patch state.
417
+ if (state.phase === STATES.PATCH_RETEST) {
418
+ next.patch_findings = null;
419
+ next.tests_to_rerun = null;
420
+ }
421
+
422
+ // Entering step 6a — carry the findings from the success.output forward.
423
+ if (newPhase === STATES.PATCH_APPLY && signal && signal.output) {
424
+ const findings = (signal.output.findings || []).filter((f) => f && f.action === 'patch');
425
+ next.patch_findings = findings;
426
+ }
427
+ if (newPhase === STATES.PATCH_RETEST && signal && signal.output && signal.output.tests_to_rerun) {
428
+ next.tests_to_rerun = signal.output.tests_to_rerun;
429
+ }
430
+
431
+ // Nano + quick-dev is one-shot per BMad's step-oneshot.md: a single
432
+ // intent → single spec → single commit. No iteration over stories or
433
+ // epics. Mark the sprint complete after the first successful NANO_QUICK_DEV
434
+ // so EPIC_BOUNDARY_CHECK routes to SPRINT_FINALIZE_PENDING (halt) instead
435
+ // of looping back to NANO_QUICK_DEV. The LLM can override by passing
436
+ // `output.sprint_is_complete: false` if they have additional stories to
437
+ // run (e.g. a sprint-status.yaml with multiple pending stories was
438
+ // pre-seeded).
439
+ if (
440
+ state.phase === STATES.NANO_QUICK_DEV &&
441
+ profile.implementation_flow === 'quick' &&
442
+ !next.sprint_is_complete
443
+ ) {
444
+ const explicitOverride = signal && signal.output && signal.output.sprint_is_complete === false;
445
+ if (!explicitOverride) {
446
+ next.sprint_is_complete = true;
447
+ }
448
+ }
449
+
450
+ // Propagate story identity from the signal so the next git_op (STORY_DONE)
451
+ // can compute the correct branch name. Without this, state.story_key
452
+ // stays null after bmad-quick-dev / bmad-create-story / bmad-dev-story
453
+ // and git-plan.js falls back to `story/unknown` — breaking epic
454
+ // granularity entirely (branchName needs current_epic to emit
455
+ // `<prefix>epic-<id>`). The signal output is the authoritative source
456
+ // for the story the LLM just worked on, so it wins over any prior
457
+ // value in state.
458
+ if (signal && signal.output) {
459
+ if (signal.output.story_key) {
460
+ next.story_key = signal.output.story_key;
461
+ }
462
+ if (signal.output.story_file_path) {
463
+ next.story_file_path = signal.output.story_file_path;
464
+ }
465
+ // Derive epic_key from story_key if the signal didn't supply it
466
+ // explicitly. Convention: story_key first segment is the epic
467
+ // identifier (e.g. `1-1-game-engine` → `1`, `epic-1-game-engine` →
468
+ // `epic-1`). If the format doesn't match, we leave current_epic
469
+ // null and branchName falls back to story-granularity (graceful
470
+ // degradation).
471
+ if (signal.output.epic_key) {
472
+ next.current_epic = signal.output.epic_key;
473
+ } else if (next.story_key) {
474
+ const derived = deriveEpicKey(next.story_key);
475
+ if (derived) next.current_epic = derived;
476
+ }
477
+ }
478
+
479
+ return next;
480
+ }
481
+
482
+ // Convention: story keys begin with the epic identifier followed by `-`.
483
+ // Examples: `1-1-game-engine` → `1`, `2-3-add-auth` → `2`. A leading
484
+ // `epic-N-...` form returns `epic-N` so the orchestrator can address
485
+ // either flavor. Returns null when the key doesn't parse cleanly.
486
+ function deriveEpicKey(storyKey) {
487
+ if (typeof storyKey !== 'string' || !storyKey) return null;
488
+ // `epic-1-...` → `epic-1`
489
+ const epicPrefixed = storyKey.match(/^(epic-[A-Za-z0-9_]+)-/);
490
+ if (epicPrefixed) return epicPrefixed[1];
491
+ // `<epic>-<story>-<slug>` → `<epic>` (first hyphen-separated segment)
492
+ const firstSeg = storyKey.match(/^([A-Za-z0-9_]+)-/);
493
+ if (firstSeg) return firstSeg[1];
494
+ return null;
495
+ }
496
+
497
+ module.exports = {
498
+ interpretSignal,
499
+ advanceState,
500
+ CONSECUTIVE_TEST_FAILURE_THRESHOLD,
501
+ SIGNAL_STATUSES,
502
+ };