@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,581 @@
1
+ #!/usr/bin/env node
2
+
3
+ // autopilot.js — orchestrator CLI.
4
+ //
5
+ // Subcommands:
6
+ // start Boot a session; emit the first action or resume divergence prompt.
7
+ // next Emit the next planned action (JSON to stdout).
8
+ // record --signal Consume a signal (JSON via stdin or --signal-file).
9
+ // state Print the current orchestrator state (YAML).
10
+ // report Print a summary of the current session.
11
+ // validate-config Resolve the active profile + report it.
12
+ // status One-line status for shell prompts and watch scripts.
13
+ //
14
+ // Single JSON object on stdout (per subcommand). Logs/warnings on stderr.
15
+ // Pure: read state → apply pure functions → write state. State lives in
16
+ // _bmad-output/implementation-artifacts/autopilot-state.yaml.
17
+ //
18
+ // All side effects route through:
19
+ // - state-store.js (state writes; honors coalesce_state_writes)
20
+ // - action-ledger.js (append-only audit log)
21
+ // - decision-log.js (decisions[] audit channel)
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ const { parseArgs } = require('../lib/runtime/args');
29
+ const log = require('../lib/runtime/log');
30
+
31
+ const stateMachine = require('../lib/orchestrator/state-machine');
32
+ const adapt = require('../lib/orchestrator/adapt');
33
+ const profileRules = require('../lib/orchestrator/profile-rules');
34
+ const verifyMod = require('../lib/orchestrator/verify');
35
+ const stateStore = require('../lib/orchestrator/state-store');
36
+ const ledger = require('../lib/orchestrator/action-ledger');
37
+ const decisionLog = require('../lib/orchestrator/decision-log');
38
+ const userCommands = require('../lib/orchestrator/user-commands');
39
+ const divergence = require('../lib/orchestrator/divergence');
40
+ const reportRenderer = require('../lib/orchestrator/report');
41
+ const gitPlan = require('../lib/orchestrator/git-plan');
42
+
43
+ const { STATES } = stateMachine;
44
+
45
+ const SUBCOMMANDS = ['start', 'next', 'record', 'state', 'report', 'validate-config', 'status'];
46
+
47
+ function help() {
48
+ log.out(
49
+ [
50
+ 'Usage:',
51
+ ' autopilot start Boot/resume the session',
52
+ ' autopilot next Emit the next planned action (JSON)',
53
+ ' autopilot record --signal <json> | --signal-file <path>',
54
+ ' autopilot state Print current state (YAML)',
55
+ ' autopilot report Session report (markdown)',
56
+ ' autopilot validate-config Resolve + print active profile',
57
+ ' autopilot status One-line status',
58
+ '',
59
+ 'Global flags:',
60
+ ' --project-root <path> Default: CWD',
61
+ ' --profile <nano|small|medium|large|legacy>',
62
+ ' Override resolved profile',
63
+ ' --help Show this help',
64
+ ].join('\n'),
65
+ );
66
+ }
67
+
68
+ // ------------------------------------------------------------ profile + state
69
+
70
+ function resolveProjectRoot(opts) {
71
+ return path.resolve(opts['project-root'] || process.cwd());
72
+ }
73
+
74
+ // Loads the resolved profile tree by shelling out to resolve-profile.js? No —
75
+ // we read the profile YAML files directly via the same logic. To avoid
76
+ // duplicating that here, we just `require` it inline. resolve-profile.js
77
+ // exports its resolver functions.
78
+ function resolveProfile(projectRoot, explicit) {
79
+ const resolver = require('../scripts/resolve-profile.js');
80
+ const r = resolver.resolveProfile(projectRoot, explicit || null);
81
+ const typed = profileRules.flatToProfile(r.resolved, r.profile);
82
+ return { resolved: r.resolved, typed, source: r.source };
83
+ }
84
+
85
+ function loadState(projectRoot) {
86
+ return stateStore.read({ projectRoot });
87
+ }
88
+
89
+ function persistState(updates, profile, projectRoot, story) {
90
+ return stateStore.write(updates, profile, { projectRoot, story });
91
+ }
92
+
93
+ // Compose the runtime `state` shape the state machine expects from the
94
+ // persisted autopilot-state.yaml. Missing fields default to fresh-session
95
+ // values; the CLI does not assume more than what's on disk.
96
+ //
97
+ // `profile` is consulted ONLY to pick the default initial phase when
98
+ // `persisted.current_bmad_step` is missing — nano (and any future
99
+ // `implementation_flow: quick` profile) boots at NANO_QUICK_DEV so the
100
+ // first emitted action is `invoke_skill: bmad-quick-dev`. This applies
101
+ // regardless of which CLI entrypoint composed the runtime (workflow.
102
+ // orchestrator.md tells the LLM to call `next` directly, bypassing
103
+ // cmdStart).
104
+ function composeRuntimeState(persisted, profile) {
105
+ const defaultPhase =
106
+ profile && profile.implementation_flow === 'quick'
107
+ ? STATES.NANO_QUICK_DEV
108
+ : STATES.CREATE_STORY;
109
+ const phase = persisted.current_bmad_step || defaultPhase;
110
+ return {
111
+ phase,
112
+ story_key: persisted.current_story || null,
113
+ story_file_path: persisted.story_file_path || null,
114
+ current_epic: persisted.current_epic || null,
115
+ ac_summary: persisted.ac_summary || null,
116
+ prior_diagnosis: persisted.prior_diagnosis || null,
117
+ relevant_decisions: persisted.relevant_decisions || [],
118
+ prior_signals_summary: persisted.prior_signals_summary || null,
119
+ patch_findings: persisted.patch_findings || null,
120
+ tests_to_rerun: persisted.tests_to_rerun || null,
121
+ remaining_stories_in_epic: persisted.remaining_stories_in_epic || 0,
122
+ sprint_is_complete: !!persisted.sprint_is_complete,
123
+ retry_count_this_phase: persisted.retry_count_this_phase || 0,
124
+ verify_reject_count: persisted.verify_reject_count || 0,
125
+ consecutive_test_failures: persisted.consecutive_test_failures || 0,
126
+ escalation_note: persisted.escalation_note || null,
127
+ // Branch reuse: persisted across resumes once detected on first boot.
128
+ user_branch: persisted.user_branch || null,
129
+ // Land-as-you-go: pending land state survives rebase-conflict halts.
130
+ land_pending: persisted.land_pending || null,
131
+ };
132
+ }
133
+
134
+ // Persist a runtime state (returned by adapt) back to the autopilot-state.yaml.
135
+ function persistRuntimeState(runtime, profile, projectRoot) {
136
+ const updates = {
137
+ current_bmad_step: runtime.phase,
138
+ current_story: runtime.story_key,
139
+ story_file_path: runtime.story_file_path,
140
+ current_epic: runtime.current_epic,
141
+ ac_summary: runtime.ac_summary,
142
+ prior_diagnosis: runtime.prior_diagnosis,
143
+ relevant_decisions: runtime.relevant_decisions,
144
+ prior_signals_summary: runtime.prior_signals_summary,
145
+ patch_findings: runtime.patch_findings,
146
+ tests_to_rerun: runtime.tests_to_rerun,
147
+ remaining_stories_in_epic: runtime.remaining_stories_in_epic,
148
+ sprint_is_complete: runtime.sprint_is_complete,
149
+ retry_count_this_phase: runtime.retry_count_this_phase,
150
+ verify_reject_count: runtime.verify_reject_count,
151
+ consecutive_test_failures: runtime.consecutive_test_failures,
152
+ user_branch: runtime.user_branch,
153
+ land_pending: runtime.land_pending,
154
+ };
155
+ return persistState(updates, profile, projectRoot, runtime.story_key || 'sprint');
156
+ }
157
+
158
+ // Detect the current git branch via plain `git rev-parse`. Returns null
159
+ // on any error (not a git repo, command missing, etc.). Pure-ish — uses
160
+ // execFileSync so callers control timeout/error policy.
161
+ function detectCurrentBranch(projectRoot) {
162
+ try {
163
+ const { execFileSync } = require('node:child_process');
164
+ return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], {
165
+ encoding: 'utf8',
166
+ timeout: 10_000,
167
+ }).trim();
168
+ } catch (_e) {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ // Emit a per-skill timing event into the legacy .timings/<story>.jsonl
174
+ // shards. This is what `observedParallelism()` reads in the e2e tests —
175
+ // having the orchestrator emit it removes the LLM-driven coupling and
176
+ // makes parallelism observable without LLM cooperation.
177
+ //
178
+ // Fire-and-forget: never halts the autopilot on failure (matches the
179
+ // legacy log-timing convention). Honors `autopilot.phase_timings: false`.
180
+ //
181
+ // log-timing.js validates `--story` against `/^[a-z0-9][a-z0-9-]*$/` so
182
+ // BMad-style keys like 'S1' or 'S1.2' must be sanitized first.
183
+ function sanitizeStoryForTiming(key) {
184
+ if (typeof key !== 'string') return 'sprint';
185
+ const lowered = key.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '');
186
+ return /^[a-z0-9][a-z0-9-]*$/.test(lowered) ? lowered : 'sprint';
187
+ }
188
+
189
+ function logSkillTiming(projectRoot, event, story, skillName, profile) {
190
+ if (profile && profile.phase_timings === false) return;
191
+ if (!skillName || !story) return;
192
+ const scriptPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'log-timing.js');
193
+ if (!fs.existsSync(scriptPath)) return;
194
+ const safeStory = sanitizeStoryForTiming(story);
195
+ try {
196
+ const { execFileSync } = require('node:child_process');
197
+ execFileSync(
198
+ 'node',
199
+ [
200
+ scriptPath,
201
+ event,
202
+ '--story',
203
+ safeStory,
204
+ '--phase',
205
+ `skill.${skillName}`,
206
+ '--project-root',
207
+ projectRoot,
208
+ ],
209
+ { stdio: 'ignore', timeout: 5_000 },
210
+ );
211
+ } catch (_e) {
212
+ // Advisory only — timing logger is fire-and-forget per the legacy contract.
213
+ }
214
+ }
215
+
216
+ // git_op actions carry an abstract `op` (e.g. commit_and_push_story).
217
+ // Inline the planned argv steps from git-plan.js so the LLM doesn't have
218
+ // to interpret the op — it just executes `action.steps` in order.
219
+ // Without this, live-LLM sessions silently skip `git push` after STORY_DONE.
220
+ function decorateGitOp(action, state, profile) {
221
+ if (!action || action.type !== 'git_op') return action;
222
+ try {
223
+ const planned = gitPlan.plan(state, profile, action);
224
+ return { ...action, branch: planned.branch, steps: planned.steps };
225
+ } catch (e) {
226
+ log.warn(`git-plan failed for op=${action.op}: ${e.message}`);
227
+ return action;
228
+ }
229
+ }
230
+
231
+ // ------------------------------------------------------------ side effects
232
+
233
+ function applySideEffects(sideEffects, runtime, profile, projectRoot) {
234
+ for (const eff of sideEffects || []) {
235
+ switch (eff.kind) {
236
+ case 'append_decisions': {
237
+ const validated = decisionLog.validateMany(eff.decisions);
238
+ if (!validated.ok) {
239
+ log.warn(`decisions validation failed: ${JSON.stringify(validated.errors)}`);
240
+ }
241
+ const valid = validated.ok ? validated.decisions : validated.valid;
242
+ if (valid && valid.length > 0) {
243
+ const logPath = path.join(
244
+ projectRoot,
245
+ '_bmad-output',
246
+ 'implementation-artifacts',
247
+ 'decision-log.yaml',
248
+ );
249
+ const result = decisionLog.append(logPath, valid, {
250
+ story: runtime.story_key || 'sprint',
251
+ });
252
+ ledger.append(
253
+ {
254
+ kind: 'decisions_appended',
255
+ story: runtime.story_key,
256
+ phase: eff.phase,
257
+ ids: result.ids,
258
+ },
259
+ { projectRoot },
260
+ );
261
+ }
262
+ break;
263
+ }
264
+ case 'apply_user_commands': {
265
+ const validated = userCommands.validate(eff.commands);
266
+ ledger.append(
267
+ {
268
+ kind: 'user_commands_applied',
269
+ phase: eff.phase,
270
+ valid: validated.ok,
271
+ commands: eff.commands,
272
+ },
273
+ { projectRoot },
274
+ );
275
+ // We log the commands; the CLI caller is responsible for actually
276
+ // applying them to the runtime state on the next `next` invocation.
277
+ // (e.g. skip_story would update sprint-status; this CLI doesn't
278
+ // touch sprint-status directly — that's BMad's domain.)
279
+ break;
280
+ }
281
+ case 'profile_escalated':
282
+ case 'log_alternative_proposed':
283
+ case 'log_verify_rejection':
284
+ case 'log_verify_override': {
285
+ const kind =
286
+ eff.kind === 'profile_escalated'
287
+ ? 'profile_escalated'
288
+ : eff.kind === 'log_alternative_proposed'
289
+ ? 'alternative_proposed'
290
+ : eff.kind === 'log_verify_rejection'
291
+ ? 'verify_rejected'
292
+ : 'verify_override';
293
+ ledger.append({ ...eff, kind }, { projectRoot });
294
+ break;
295
+ }
296
+ default:
297
+ // Unknown side-effect kinds are recorded but otherwise ignored.
298
+ ledger.append({ kind: 'state_transition', detail: eff }, { projectRoot });
299
+ }
300
+ }
301
+ }
302
+
303
+ // ------------------------------------------------------------ subcommands
304
+
305
+ function cmdStart(opts) {
306
+ const projectRoot = resolveProjectRoot(opts);
307
+ const { typed: profile } = resolveProfile(projectRoot, opts.profile);
308
+ const persisted = loadState(projectRoot);
309
+
310
+ // Resume detection: if a prior session left a fingerprint, diff.
311
+ const lastHalt = ledger.last({ projectRoot }, 'halt');
312
+ if (lastHalt && lastHalt.fingerprint) {
313
+ const d = divergence.detect({ projectRoot }, lastHalt.fingerprint);
314
+ if (!d.identical) {
315
+ const result = {
316
+ kind: 'resume_divergence',
317
+ differences: d.differences,
318
+ last_phase: persisted.current_bmad_step || null,
319
+ };
320
+ ledger.append({ kind: 'resume', divergence: result }, { projectRoot });
321
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
322
+ return 0;
323
+ }
324
+ }
325
+
326
+ // Fresh start or clean resume. `composeRuntimeState` applies the
327
+ // profile-aware initial phase when persisted state is empty.
328
+ const runtime = composeRuntimeState(persisted, profile);
329
+
330
+ // Branch reuse: on first boot under reuse_user_branch=true, detect the
331
+ // current git branch and lock it in. The state machine + git-plan then
332
+ // commit every story onto this branch.
333
+ if (profile.reuse_user_branch && !runtime.user_branch) {
334
+ const current = detectCurrentBranch(projectRoot);
335
+ const base = profile.base_branch || 'main';
336
+ if (!current) {
337
+ const halt = {
338
+ type: 'halt',
339
+ reason: 'reuse_user_branch_no_git',
340
+ prompt:
341
+ 'reuse_user_branch is on but git is not available / no current branch detected. Initialize a git repo and check out the branch you want autopilot to use.',
342
+ };
343
+ ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
344
+ process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
345
+ return 0;
346
+ }
347
+ if (current === base) {
348
+ const halt = {
349
+ type: 'user_prompt',
350
+ reason: 'reuse_user_branch_on_base',
351
+ prompt: `reuse_user_branch is on but you're on the base branch (${base}). Create + checkout the branch you want autopilot to commit on, then re-run.`,
352
+ };
353
+ ledger.append({ kind: 'action_emitted', phase: runtime.phase, action: halt }, { projectRoot });
354
+ process.stdout.write(`${JSON.stringify({ action: halt, phase: runtime.phase }, null, 2)}\n`);
355
+ return 0;
356
+ }
357
+ runtime.user_branch = current;
358
+ ledger.append(
359
+ { kind: 'state_transition', detail: { user_branch_detected: current } },
360
+ { projectRoot },
361
+ );
362
+ }
363
+
364
+ const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
365
+ ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
366
+ persistRuntimeState(runtime, profile, projectRoot);
367
+ if (profile.coalesce_state_writes) stateStore.flush(profile, { projectRoot, story: runtime.story_key });
368
+ process.stdout.write(`${JSON.stringify({ action, phase: runtime.phase }, null, 2)}\n`);
369
+ return 0;
370
+ }
371
+
372
+ function cmdNext(opts) {
373
+ const projectRoot = resolveProjectRoot(opts);
374
+ const { typed: profile } = resolveProfile(projectRoot, opts.profile);
375
+ const persisted = loadState(projectRoot);
376
+ const runtime = composeRuntimeState(persisted, profile);
377
+ const action = decorateGitOp(stateMachine.nextAction(runtime, profile), runtime, profile);
378
+ ledger.append({ kind: 'action_emitted', phase: runtime.phase, action }, { projectRoot });
379
+ // Skill timing: emit a `skill.<name>` start event when we hand off an
380
+ // invoke_skill action. The matching end event is emitted on `record`
381
+ // when the signal advances the phase. This makes parallelism +
382
+ // duration observable without depending on LLM cooperation.
383
+ if (action.type === 'invoke_skill' && action.skill) {
384
+ logSkillTiming(projectRoot, 'start', runtime.story_key || 'sprint', action.skill, profile);
385
+ }
386
+ process.stdout.write(`${JSON.stringify({ action, phase: runtime.phase }, null, 2)}\n`);
387
+ return 0;
388
+ }
389
+
390
+ function cmdRecord(opts) {
391
+ const projectRoot = resolveProjectRoot(opts);
392
+ const { typed: profile } = resolveProfile(projectRoot, opts.profile);
393
+ const persisted = loadState(projectRoot);
394
+ const runtime = composeRuntimeState(persisted, profile);
395
+
396
+ let signalJson;
397
+ if (opts['signal-file']) {
398
+ signalJson = fs.readFileSync(opts['signal-file'], 'utf8');
399
+ } else if (opts.signal) {
400
+ signalJson = String(opts.signal);
401
+ } else {
402
+ signalJson = fs.readFileSync(0, 'utf8');
403
+ }
404
+ let signal;
405
+ try {
406
+ signal = JSON.parse(signalJson);
407
+ } catch (e) {
408
+ log.error(`invalid signal JSON: ${e.message}`);
409
+ return 2;
410
+ }
411
+ ledger.append(
412
+ { kind: 'signal_recorded', phase: runtime.phase, status: signal.status },
413
+ { projectRoot },
414
+ );
415
+
416
+ // Verify only on `success` and `verify_override`.
417
+ let verifyResult;
418
+ if (signal.status === 'success') {
419
+ verifyResult = verifyMod.verify(runtime, signal.output, { projectRoot });
420
+ ledger.append(
421
+ { kind: 'verify_result', phase: runtime.phase, ok: verifyResult.ok, issues: verifyResult.issues || [] },
422
+ { projectRoot },
423
+ );
424
+ } else if (signal.status === 'verify_override') {
425
+ verifyResult = verifyMod.verifyWithOverride(
426
+ runtime,
427
+ signal.output || {},
428
+ { projectRoot },
429
+ signal.evidence || {},
430
+ );
431
+ ledger.append(
432
+ { kind: 'verify_result', phase: runtime.phase, ok: verifyResult.ok, issues: verifyResult.issues || [] },
433
+ { projectRoot },
434
+ );
435
+ }
436
+
437
+ const result = adapt.interpretSignal(runtime, signal, profile, verifyResult);
438
+ applySideEffects(result.sideEffects, result.newState, result.newProfile, projectRoot);
439
+
440
+ // Skill timing: emit `skill.<name>` end event when an invoke_skill phase
441
+ // advances to a new phase (success path) OR when it pauses with a
442
+ // non-retry verdict (failure/prompted). Match the legacy log-timing
443
+ // bracket semantics so observedParallelism() sees a complete interval.
444
+ const wasInvokeSkill =
445
+ runtime.phase &&
446
+ ['create_story', 'check_readiness', 'dev_red', 'dev_green', 'code_review',
447
+ 'patch_apply', 'patch_retest', 'retrospective', 'nano_quick_dev'].includes(runtime.phase);
448
+ if (wasInvokeSkill && result.verdict !== 'retry') {
449
+ const skillFromAction = (() => {
450
+ const a = stateMachine.nextAction(runtime, profile);
451
+ return a && a.type === 'invoke_skill' ? a.skill : null;
452
+ })();
453
+ if (skillFromAction) {
454
+ logSkillTiming(
455
+ projectRoot,
456
+ 'end',
457
+ runtime.story_key || 'sprint',
458
+ skillFromAction,
459
+ result.newProfile,
460
+ );
461
+ }
462
+ }
463
+
464
+ // Persist new runtime state.
465
+ persistRuntimeState(result.newState, result.newProfile, projectRoot);
466
+
467
+ // Story-boundary or halt → flush coalesce buffer if enabled.
468
+ const isStoryBoundary =
469
+ result.newState.phase === STATES.STORY_DONE ||
470
+ result.newState.phase === STATES.EPIC_BOUNDARY_CHECK ||
471
+ result.newState.phase === STATES.SPRINT_FINALIZE_PENDING ||
472
+ result.verdict === 'halt';
473
+ if (result.newProfile.coalesce_state_writes && isStoryBoundary) {
474
+ stateStore.flush(result.newProfile, { projectRoot, story: result.newState.story_key });
475
+ }
476
+
477
+ // On halt: record fingerprint for resume divergence detection.
478
+ if (result.verdict === 'halt' || (result.nextAction && result.nextAction.type === 'halt')) {
479
+ const fp = divergence.fingerprint({ projectRoot });
480
+ ledger.append(
481
+ { kind: 'halt', phase: result.newState.phase, reason: result.nextAction.reason, fingerprint: fp },
482
+ { projectRoot },
483
+ );
484
+ } else {
485
+ ledger.append(
486
+ { kind: 'state_transition', from: runtime.phase, to: result.newState.phase, verdict: result.verdict },
487
+ { projectRoot },
488
+ );
489
+ }
490
+
491
+ const payload = {
492
+ action: decorateGitOp(result.nextAction, result.newState, result.newProfile),
493
+ verdict: result.verdict,
494
+ phase: result.newState.phase,
495
+ profile: result.newProfile.name,
496
+ };
497
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
498
+ return 0;
499
+ }
500
+
501
+ function cmdState(opts) {
502
+ const projectRoot = resolveProjectRoot(opts);
503
+ const persisted = loadState(projectRoot);
504
+ process.stdout.write(`${JSON.stringify(persisted, null, 2)}\n`);
505
+ return 0;
506
+ }
507
+
508
+ function cmdReport(opts) {
509
+ const projectRoot = resolveProjectRoot(opts);
510
+ const { typed: profile } = resolveProfile(projectRoot, opts.profile);
511
+ const persisted = loadState(projectRoot);
512
+ const entries = ledger.read({ projectRoot });
513
+ process.stdout.write(`${reportRenderer.render(persisted, entries, profile)}\n`);
514
+ return 0;
515
+ }
516
+
517
+ function cmdValidateConfig(opts) {
518
+ const projectRoot = resolveProjectRoot(opts);
519
+ const { typed, source } = resolveProfile(projectRoot, opts.profile);
520
+ process.stdout.write(`${JSON.stringify({ profile: typed, source }, null, 2)}\n`);
521
+ return 0;
522
+ }
523
+
524
+ function cmdStatus(opts) {
525
+ const projectRoot = resolveProjectRoot(opts);
526
+ const persisted = loadState(projectRoot);
527
+ const story = persisted.current_story || '-';
528
+ const step = persisted.current_bmad_step || '-';
529
+ process.stdout.write(`story=${story} step=${step}\n`);
530
+ return 0;
531
+ }
532
+
533
+ // ------------------------------------------------------------ main
534
+
535
+ function main(argv) {
536
+ const { opts, positional } = parseArgs(argv, { booleanFlags: ['help'] });
537
+ if (opts.help) {
538
+ help();
539
+ return 0;
540
+ }
541
+ // First positional = subcommand.
542
+ const sub = positional[0];
543
+ if (!sub) {
544
+ help();
545
+ return 1;
546
+ }
547
+ if (!SUBCOMMANDS.includes(sub)) {
548
+ log.error(`unknown subcommand: ${sub}`);
549
+ help();
550
+ return 2;
551
+ }
552
+ try {
553
+ switch (sub) {
554
+ case 'start':
555
+ return cmdStart(opts);
556
+ case 'next':
557
+ return cmdNext(opts);
558
+ case 'record':
559
+ return cmdRecord(opts);
560
+ case 'state':
561
+ return cmdState(opts);
562
+ case 'report':
563
+ return cmdReport(opts);
564
+ case 'validate-config':
565
+ return cmdValidateConfig(opts);
566
+ case 'status':
567
+ return cmdStatus(opts);
568
+ default:
569
+ return 2;
570
+ }
571
+ } catch (e) {
572
+ log.error(`autopilot ${sub}: ${e.message}`);
573
+ return 1;
574
+ }
575
+ }
576
+
577
+ if (require.main === module) {
578
+ process.exit(main(process.argv.slice(2)));
579
+ }
580
+
581
+ module.exports = { main, SUBCOMMANDS };