@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.
Files changed (42) 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/stack-snapshot.js +128 -0
  35. package/_Sprintpilot/scripts/state-shard.js +8 -1
  36. package/_Sprintpilot/scripts/summarize-timings.js +30 -12
  37. package/_Sprintpilot/scripts/with-retry.js +17 -5
  38. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
  39. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
  40. package/lib/core/update-check.js +11 -1
  41. package/package.json +1 -1
  42. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
@@ -103,7 +103,10 @@ function validatePhase(s) {
103
103
 
104
104
  function validateAction(a) {
105
105
  if (!VALID_ACTIONS.includes(a)) {
106
- return { ok: false, error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}` };
106
+ return {
107
+ ok: false,
108
+ error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}`,
109
+ };
107
110
  }
108
111
  return { ok: true, value: a };
109
112
  }
@@ -430,10 +433,16 @@ function main() {
430
433
  const r = markPhase(projectRoot, story.value, phase.value, meta.value);
431
434
  // Emit a brief JSON line so callers can log the duration if useful.
432
435
  // Stdout is intentionally separate from the per-story shard.
433
- process.stdout.write(`${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`);
436
+ process.stdout.write(
437
+ `${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`,
438
+ );
434
439
  return;
435
440
  }
436
- appendLine(projectRoot, story.value, buildEntry(action.value, story.value, phase.value, meta.value));
441
+ appendLine(
442
+ projectRoot,
443
+ story.value,
444
+ buildEntry(action.value, story.value, phase.value, meta.value),
445
+ );
437
446
  } catch (e) {
438
447
  log.error(`timing write failed: ${e.message}`);
439
448
  process.exit(1);
@@ -115,7 +115,9 @@ function acquireMergeLock(projectRoot) {
115
115
  try {
116
116
  const st = fs.statSync(file);
117
117
  if (Date.now() - st.mtimeMs > STALE_LOCK_AGE_MS) {
118
- log.warn(`merge-shards: removing stale lock ${file} (older than ${STALE_LOCK_AGE_MS}ms)`);
118
+ log.warn(
119
+ `merge-shards: removing stale lock ${file} (older than ${STALE_LOCK_AGE_MS}ms)`,
120
+ );
119
121
  fs.unlinkSync(file);
120
122
  continue;
121
123
  }
@@ -201,11 +203,7 @@ function shardUnchanged(file, snapshot) {
201
203
  if (!snapshot) return false;
202
204
  try {
203
205
  const st = fs.statSync(file);
204
- return (
205
- st.mtimeMs === snapshot.mtime &&
206
- st.size === snapshot.size &&
207
- st.ino === snapshot.ino
208
- );
206
+ return st.mtimeMs === snapshot.mtime && st.size === snapshot.size && st.ino === snapshot.ino;
209
207
  } catch {
210
208
  return false;
211
209
  }
@@ -421,7 +419,11 @@ function composeStateYaml(stateMerge) {
421
419
  if (stateMerge.corrupt.length + stateMerge.invalid.length > 0) {
422
420
  doc.shard_problems = [
423
421
  ...stateMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
424
- ...stateMerge.invalid.map((c) => ({ story: c.story, kind: 'invalid-shape', detail: c.reason })),
422
+ ...stateMerge.invalid.map((c) => ({
423
+ story: c.story,
424
+ kind: 'invalid-shape',
425
+ detail: c.reason,
426
+ })),
425
427
  ];
426
428
  }
427
429
  return `${yamlDump(doc)}\n`;
@@ -435,8 +437,16 @@ function composeDecisionYaml(decisionMerge) {
435
437
  };
436
438
  if (decisionMerge.corrupt.length + decisionMerge.invalid.length > 0) {
437
439
  doc.shard_problems = [
438
- ...decisionMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
439
- ...decisionMerge.invalid.map((c) => ({ story: c.story, kind: 'invalid-shape', detail: c.reason })),
440
+ ...decisionMerge.corrupt.map((c) => ({
441
+ story: c.story,
442
+ kind: 'parse-error',
443
+ detail: c.error,
444
+ })),
445
+ ...decisionMerge.invalid.map((c) => ({
446
+ story: c.story,
447
+ kind: 'invalid-shape',
448
+ detail: c.reason,
449
+ })),
440
450
  ];
441
451
  }
442
452
  return `${yamlDump(doc)}\n`;
@@ -460,7 +470,13 @@ function merge(projectRoot, { layerId, archive, dryRun } = {}) {
460
470
  archivedCorrupt.push({ kind: 'state', story: c.story, ...arch });
461
471
  }
462
472
  for (const c of decisions.corrupt.concat(decisions.invalid)) {
463
- const arch = archiveCorrupt(projectRoot, 'decision-log', c.story, c.file, c.error || c.reason);
473
+ const arch = archiveCorrupt(
474
+ projectRoot,
475
+ 'decision-log',
476
+ c.story,
477
+ c.file,
478
+ c.error || c.reason,
479
+ );
464
480
  archivedCorrupt.push({ kind: 'decision-log', story: c.story, ...arch });
465
481
  }
466
482
  }
@@ -468,8 +484,12 @@ function merge(projectRoot, { layerId, archive, dryRun } = {}) {
468
484
  const stateBody = composeStateYaml(state);
469
485
  const decisionBody = composeDecisionYaml(decisions);
470
486
 
471
- const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, { dryRun });
472
- const decisionWrite = writeAuthoritative(projectRoot, 'decision-log.yaml', decisionBody, { dryRun });
487
+ const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, {
488
+ dryRun,
489
+ });
490
+ const decisionWrite = writeAuthoritative(projectRoot, 'decision-log.yaml', decisionBody, {
491
+ dryRun,
492
+ });
473
493
 
474
494
  let archiveDir = null;
475
495
  let archiveSkipped = [];
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ // post-green-gates.js — composed post-GREEN quality pipeline.
4
+ //
5
+ // Called by the orchestrator after a `bmad-dev-story` GREEN phase
6
+ // completes verify. Runs three gates in order; first failing gate causes
7
+ // non-zero exit with a structured JSON report.
8
+ //
9
+ // Gates:
10
+ // 1. lint-changed.js — biome/eslint on changed files only
11
+ // 2. lint-test-pitfalls.js — LLM-test pitfalls (only on test files)
12
+ // 3. scan.js (ci-parity) — search for obvious CI-only failure modes
13
+ // (envs hard-coded to local-only assumptions)
14
+ //
15
+ // Usage:
16
+ // post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]
17
+ // --changed-files: path to a newline-delimited list of changed files
18
+ // (default: derive from `git diff --name-only HEAD`)
19
+ //
20
+ // Each gate runs in a child process via execFileSync. Argv-only — no shell.
21
+
22
+ const { execFileSync, spawnSync } = require('node:child_process');
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const { parseArgs } = require('../lib/runtime/args');
27
+ const log = require('../lib/runtime/log');
28
+
29
+ function help() {
30
+ log.out(
31
+ [
32
+ 'Usage: post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]',
33
+ '',
34
+ 'Runs in order:',
35
+ ' 1. lint-changed.js — formatter + linter on changed files',
36
+ ' 2. lint-test-pitfalls.js — LLM-test pitfall scan (test files only)',
37
+ ' 3. scan.js (ci-parity) — CI-only failure mode scan',
38
+ ].join('\n'),
39
+ );
40
+ }
41
+
42
+ function listChangedFiles(projectRoot, override) {
43
+ if (override) {
44
+ return fs
45
+ .readFileSync(override, 'utf8')
46
+ .split(/\r?\n/)
47
+ .map((s) => s.trim())
48
+ .filter((s) => s.length > 0);
49
+ }
50
+ try {
51
+ const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
52
+ cwd: projectRoot,
53
+ encoding: 'utf8',
54
+ });
55
+ return out
56
+ .split(/\r?\n/)
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ } catch (_e) {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function runGate(name, command, args, projectRoot) {
65
+ const r = spawnSync(command, args, { cwd: projectRoot, encoding: 'utf8' });
66
+ return {
67
+ gate: name,
68
+ ok: r.status === 0,
69
+ exit_code: r.status === null ? -1 : r.status,
70
+ stdout: r.stdout || '',
71
+ stderr: r.stderr || '',
72
+ };
73
+ }
74
+
75
+ function isTestFile(p) {
76
+ return /\.test\.(ts|tsx|js|jsx|mts)$/.test(p) || /\.spec\.(ts|tsx|js|jsx|mts)$/.test(p);
77
+ }
78
+
79
+ function isJsTsFile(p) {
80
+ return /\.(ts|tsx|js|jsx|mts|cjs)$/.test(p);
81
+ }
82
+
83
+ function main(argv) {
84
+ const { opts } = parseArgs(argv, { booleanFlags: ['json', 'help'] });
85
+ if (opts.help) {
86
+ help();
87
+ return 0;
88
+ }
89
+ const projectRoot = path.resolve(opts['project-root'] || process.cwd());
90
+ const changed = listChangedFiles(projectRoot, opts['changed-files']);
91
+ const jsTs = changed.filter(isJsTsFile);
92
+ const testFiles = changed.filter(isTestFile);
93
+
94
+ const gates = [];
95
+
96
+ // Gate 1: lint-changed.
97
+ const lintChangedPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-changed.js');
98
+ if (fs.existsSync(lintChangedPath) && jsTs.length > 0) {
99
+ gates.push(
100
+ runGate(
101
+ 'lint-changed',
102
+ 'node',
103
+ [lintChangedPath, '--project-root', projectRoot],
104
+ projectRoot,
105
+ ),
106
+ );
107
+ } else {
108
+ gates.push({
109
+ gate: 'lint-changed',
110
+ ok: true,
111
+ exit_code: 0,
112
+ stdout: 'skipped (no JS/TS changes or script missing)',
113
+ stderr: '',
114
+ });
115
+ }
116
+
117
+ // Gate 2: lint-test-pitfalls — only run on test files.
118
+ const pitfallsPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-test-pitfalls.js');
119
+ if (fs.existsSync(pitfallsPath) && testFiles.length > 0) {
120
+ gates.push(runGate('lint-test-pitfalls', 'node', [pitfallsPath, ...testFiles], projectRoot));
121
+ } else {
122
+ gates.push({
123
+ gate: 'lint-test-pitfalls',
124
+ ok: true,
125
+ exit_code: 0,
126
+ stdout: 'skipped (no test files in change set)',
127
+ stderr: '',
128
+ });
129
+ }
130
+
131
+ // Gate 3: ci-parity via scan.js. Pattern set is intentionally conservative
132
+ // — flag obvious CI-only-fail patterns: `if (!process.env.CI)` skips,
133
+ // hardcoded localhost ports, `xit`/`xdescribe`. scan.js does the search;
134
+ // we treat its non-zero exit as a block.
135
+ const scanPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'scan.js');
136
+ if (fs.existsSync(scanPath) && jsTs.length > 0) {
137
+ gates.push(
138
+ runGate(
139
+ 'ci-parity',
140
+ 'node',
141
+ [
142
+ scanPath,
143
+ '--pattern',
144
+ 'process.env.CI',
145
+ '--pattern',
146
+ '(localhost|127\\.0\\.0\\.1):\\d{4,5}',
147
+ '--paths',
148
+ ...jsTs,
149
+ ],
150
+ projectRoot,
151
+ ),
152
+ );
153
+ } else {
154
+ gates.push({
155
+ gate: 'ci-parity',
156
+ ok: true,
157
+ exit_code: 0,
158
+ stdout: 'skipped (no JS/TS changes or script missing)',
159
+ stderr: '',
160
+ });
161
+ }
162
+
163
+ const firstFail = gates.find((g) => !g.ok);
164
+ const overallOk = !firstFail;
165
+
166
+ if (opts.json) {
167
+ process.stdout.write(
168
+ `${JSON.stringify({ ok: overallOk, gates, first_fail: firstFail?.gate || null }, null, 2)}\n`,
169
+ );
170
+ } else {
171
+ for (const g of gates) {
172
+ log.out(`[${g.ok ? '✓' : '✗'}] ${g.gate}: exit=${g.exit_code}`);
173
+ if (!g.ok) {
174
+ if (g.stdout) log.out(g.stdout);
175
+ if (g.stderr) log.err(g.stderr);
176
+ }
177
+ }
178
+ log.out(overallOk ? 'all gates passed' : `failed gate: ${firstFail?.gate}`);
179
+ }
180
+ return overallOk ? 0 : 1;
181
+ }
182
+
183
+ if (require.main === module) {
184
+ process.exit(main(process.argv.slice(2)));
185
+ }
186
+
187
+ module.exports = { main, listChangedFiles, runGate, isTestFile, isJsTsFile };
@@ -196,7 +196,8 @@ function main() {
196
196
  process.exit(1);
197
197
  }
198
198
  const projectRoot = opts['project-root'] || process.cwd();
199
- const branchPrefix = opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
199
+ const branchPrefix =
200
+ opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
200
201
  const timeout = opts['lock-timeout-sec']
201
202
  ? Number.parseInt(String(opts['lock-timeout-sec']), 10)
202
203
  : DEFAULT_LOCK_TIMEOUT_SEC;
@@ -568,7 +568,9 @@ function main() {
568
568
  }
569
569
 
570
570
  if (command === 'graph') {
571
- process.stdout.write(`${JSON.stringify({ nodes: dag.nodes, edges: dag.edges, epic: dag.epic })}\n`);
571
+ process.stdout.write(
572
+ `${JSON.stringify({ nodes: dag.nodes, edges: dag.edges, epic: dag.epic })}\n`,
573
+ );
572
574
  return;
573
575
  }
574
576
  if (command === 'layers') {
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ // stack-snapshot.js — capture a snapshot of the current per-story branch
4
+ // stack so `land-this-pr.js` can land the active PR without losing the
5
+ // rest of the in-flight stack.
6
+ //
7
+ // Output: JSON to stdout (or --output <path>). Shape:
8
+ // {
9
+ // base_branch: string,
10
+ // ts: ISO,
11
+ // branches: [{ name, head, story_key, status, parent? }],
12
+ // active_pr: { branch, number?, story_key } | null,
13
+ // }
14
+ //
15
+ // Argv-only. Uses `git -C <projectRoot>` for every git call.
16
+
17
+ const { execFileSync } = require('node:child_process');
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const { parseArgs } = require('../lib/runtime/args');
22
+ const log = require('../lib/runtime/log');
23
+
24
+ function help() {
25
+ log.out(
26
+ [
27
+ 'Usage: stack-snapshot.js [--project-root <path>] [--base-branch <name>]',
28
+ ' [--output <file>] [--active-branch <name>]',
29
+ ' [--story-key <key>] [--pr-number <n>]',
30
+ ].join('\n'),
31
+ );
32
+ }
33
+
34
+ function git(projectRoot, args) {
35
+ try {
36
+ return execFileSync('git', ['-C', projectRoot, ...args], { encoding: 'utf8' }).trim();
37
+ } catch (_e) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function listLocalBranches(projectRoot, prefix) {
43
+ const raw = git(projectRoot, [
44
+ 'for-each-ref',
45
+ '--format=%(refname:short)\t%(objectname)',
46
+ `refs/heads/${prefix}`,
47
+ ]);
48
+ if (!raw) return [];
49
+ return raw
50
+ .split(/\n/)
51
+ .filter(Boolean)
52
+ .map((line) => {
53
+ const [name, head] = line.split('\t');
54
+ return { name, head };
55
+ });
56
+ }
57
+
58
+ function readSprintStatus(projectRoot) {
59
+ const p = path.join(
60
+ projectRoot,
61
+ '_bmad-output',
62
+ 'implementation-artifacts',
63
+ 'sprint-status.yaml',
64
+ );
65
+ if (!fs.existsSync(p)) return null;
66
+ try {
67
+ return fs.readFileSync(p, 'utf8');
68
+ } catch (_e) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function statusForStory(sprintStatusText, storyKey) {
74
+ if (!sprintStatusText) return 'unknown';
75
+ // Narrow regex — accept either "story_key: status" or block form.
76
+ const re = new RegExp(`^\\s*${storyKey}:\\s*(\\w+)`, 'm');
77
+ const m = sprintStatusText.match(re);
78
+ return m ? m[1] : 'unknown';
79
+ }
80
+
81
+ function snapshot(opts) {
82
+ const projectRoot = path.resolve(opts['project-root'] || process.cwd());
83
+ const baseBranch = opts['base-branch'] || 'main';
84
+ const branches = listLocalBranches(projectRoot, 'story/').map((b) => ({
85
+ ...b,
86
+ story_key: b.name.slice('story/'.length),
87
+ }));
88
+ const sprintStatus = readSprintStatus(projectRoot);
89
+ for (const b of branches) {
90
+ b.status = statusForStory(sprintStatus, b.story_key);
91
+ }
92
+ const active =
93
+ opts['active-branch'] && opts['story-key']
94
+ ? {
95
+ branch: opts['active-branch'],
96
+ story_key: opts['story-key'],
97
+ number: opts['pr-number'] ? Number(opts['pr-number']) : undefined,
98
+ }
99
+ : null;
100
+ return {
101
+ base_branch: baseBranch,
102
+ ts: new Date().toISOString(),
103
+ branches,
104
+ active_pr: active,
105
+ };
106
+ }
107
+
108
+ function main(argv) {
109
+ const { opts } = parseArgs(argv, { booleanFlags: ['help'] });
110
+ if (opts.help) {
111
+ help();
112
+ return 0;
113
+ }
114
+ const snap = snapshot(opts);
115
+ const out = `${JSON.stringify(snap, null, 2)}\n`;
116
+ if (opts.output) {
117
+ fs.writeFileSync(path.resolve(opts.output), out, 'utf8');
118
+ } else {
119
+ process.stdout.write(out);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ if (require.main === module) {
125
+ process.exit(main(process.argv.slice(2)));
126
+ }
127
+
128
+ module.exports = { main, snapshot, statusForStory };
@@ -575,7 +575,14 @@ function deepAssign(target, source) {
575
575
  for (const k of Object.keys(source)) {
576
576
  const sv = source[k];
577
577
  const tv = out[k];
578
- if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
578
+ if (
579
+ sv &&
580
+ typeof sv === 'object' &&
581
+ !Array.isArray(sv) &&
582
+ tv &&
583
+ typeof tv === 'object' &&
584
+ !Array.isArray(tv)
585
+ ) {
579
586
  out[k] = deepAssign(tv, sv);
580
587
  } else {
581
588
  out[k] = sv;
@@ -176,7 +176,12 @@ function pairEvents(events) {
176
176
  for (const key of Object.keys(openByStoryPhase)) {
177
177
  const [story, phase] = key.split('::');
178
178
  for (const startMs of openByStoryPhase[key]) {
179
- orphans.push({ story, phase, event: 'start-without-end', ts: new Date(startMs).toISOString() });
179
+ orphans.push({
180
+ story,
181
+ phase,
182
+ event: 'start-without-end',
183
+ ts: new Date(startMs).toISOString(),
184
+ });
180
185
  }
181
186
  }
182
187
 
@@ -213,14 +218,21 @@ function aggregate(paired) {
213
218
  withPct.sort((a, b) => b.sum_ms - a.sum_ms);
214
219
  const hotspots = withPct.filter((r) => r.pct_of_total > HOTSPOT_THRESHOLD);
215
220
 
216
- const stories = Object.keys(paired.stories).sort().map((key) => {
217
- const s = paired.stories[key];
218
- const wall_ms = s.first !== null && s.last !== null ? s.last - s.first : 0;
219
- const phaseSum = Object.values(s.phases)
220
- .flat()
221
- .reduce((acc, v) => acc + v, 0);
222
- return { story: key, wall_ms, phase_sum_ms: phaseSum, phase_count: Object.keys(s.phases).length };
223
- });
221
+ const stories = Object.keys(paired.stories)
222
+ .sort()
223
+ .map((key) => {
224
+ const s = paired.stories[key];
225
+ const wall_ms = s.first !== null && s.last !== null ? s.last - s.first : 0;
226
+ const phaseSum = Object.values(s.phases)
227
+ .flat()
228
+ .reduce((acc, v) => acc + v, 0);
229
+ return {
230
+ story: key,
231
+ wall_ms,
232
+ phase_sum_ms: phaseSum,
233
+ phase_count: Object.keys(s.phases).length,
234
+ };
235
+ });
224
236
 
225
237
  return {
226
238
  total_paired_ms: totalPaired,
@@ -263,7 +275,9 @@ function renderText(report) {
263
275
  if (report.phases.length === 0) {
264
276
  lines.push(' (no paired start/end events)');
265
277
  } else {
266
- lines.push(' phase count sum p50 p95 max %');
278
+ lines.push(
279
+ ' phase count sum p50 p95 max %',
280
+ );
267
281
  for (const r of report.phases) {
268
282
  lines.push(
269
283
  ` ${r.phase.padEnd(40)} ${String(r.count).padStart(5)} ${fmtMs(r.sum_ms).padStart(6)} ${fmtMs(r.p50_ms).padStart(6)} ${fmtMs(r.p95_ms).padStart(6)} ${fmtMs(r.max_ms).padStart(6)} ${fmtPct(r.pct_of_total).padStart(6)}`,
@@ -318,7 +332,9 @@ function renderMarkdown(report) {
318
332
  lines.push('| Story | Wall | Phase sum | # phases |');
319
333
  lines.push('|---|---|---|---|');
320
334
  for (const s of report.stories) {
321
- lines.push(`| ${s.story} | ${fmtMs(s.wall_ms)} | ${fmtMs(s.phase_sum_ms)} | ${s.phase_count} |`);
335
+ lines.push(
336
+ `| ${s.story} | ${fmtMs(s.wall_ms)} | ${fmtMs(s.phase_sum_ms)} | ${s.phase_count} |`,
337
+ );
322
338
  }
323
339
  }
324
340
  lines.push('');
@@ -360,7 +376,9 @@ function renderMarkdown(report) {
360
376
  lines.push('');
361
377
  lines.push('## Anomalies');
362
378
  lines.push('');
363
- lines.push('_Excluded from p50/p95/max so a single skew/stale-marker doesn\'t poison aggregates._');
379
+ lines.push(
380
+ "_Excluded from p50/p95/max so a single skew/stale-marker doesn't poison aggregates._",
381
+ );
364
382
  lines.push('');
365
383
  lines.push('| Phase | clock_skew | over_threshold |');
366
384
  lines.push('|---|---:|---:|');
@@ -27,7 +27,8 @@ const log = require('../lib/runtime/log');
27
27
  const DEFAULT_ATTEMPTS = 3;
28
28
  const DEFAULT_MIN_MS = 500;
29
29
  const DEFAULT_MAX_MS = 2000;
30
- const DEFAULT_REF_LOCK_PATTERN = /cannot lock ref|Unable to create.*\.lock|Reference already exists|failed to lock|lock\.ref/i;
30
+ const DEFAULT_REF_LOCK_PATTERN =
31
+ /cannot lock ref|Unable to create.*\.lock|Reference already exists|failed to lock|lock\.ref/i;
31
32
 
32
33
  function help() {
33
34
  log.out(
@@ -79,7 +80,15 @@ function runOnce(cmd, args, inherit = false) {
79
80
  };
80
81
  }
81
82
 
82
- function runWithRetry({ cmd, args, attempts = DEFAULT_ATTEMPTS, minMs = DEFAULT_MIN_MS, maxMs = DEFAULT_MAX_MS, pattern = DEFAULT_REF_LOCK_PATTERN, onAttempt = null }) {
83
+ function runWithRetry({
84
+ cmd,
85
+ args,
86
+ attempts = DEFAULT_ATTEMPTS,
87
+ minMs = DEFAULT_MIN_MS,
88
+ maxMs = DEFAULT_MAX_MS,
89
+ pattern = DEFAULT_REF_LOCK_PATTERN,
90
+ onAttempt = null,
91
+ }) {
83
92
  const actualAttempts = Math.max(1, attempts | 0);
84
93
  let last = null;
85
94
  for (let i = 0; i < actualAttempts; i++) {
@@ -107,9 +116,12 @@ function main() {
107
116
  help();
108
117
  process.exit(opts.help ? 0 : 1);
109
118
  }
110
- const attempts = opts.attempts !== undefined ? Number.parseInt(String(opts.attempts), 10) : DEFAULT_ATTEMPTS;
111
- const minMs = opts['min-ms'] !== undefined ? Number.parseInt(String(opts['min-ms']), 10) : DEFAULT_MIN_MS;
112
- const maxMs = opts['max-ms'] !== undefined ? Number.parseInt(String(opts['max-ms']), 10) : DEFAULT_MAX_MS;
119
+ const attempts =
120
+ opts.attempts !== undefined ? Number.parseInt(String(opts.attempts), 10) : DEFAULT_ATTEMPTS;
121
+ const minMs =
122
+ opts['min-ms'] !== undefined ? Number.parseInt(String(opts['min-ms']), 10) : DEFAULT_MIN_MS;
123
+ const maxMs =
124
+ opts['max-ms'] !== undefined ? Number.parseInt(String(opts['max-ms']), 10) : DEFAULT_MAX_MS;
113
125
  let pattern = DEFAULT_REF_LOCK_PATTERN;
114
126
  if (opts.pattern) {
115
127
  try {
@@ -3,4 +3,26 @@ name: sprint-autopilot-on
3
3
  description: 'Engage autonomous story execution for BMad Method with git workflow integration. Implements stories end-to-end with automatic branching (git worktrees), commits, linting, and PR creation. Uses standard git worktree commands for story isolation — works with any coding agent. Falls back to stock BMad behavior when git is disabled. Use when user says "/sprint-autopilot-on" or "start autopilot".'
4
4
  ---
5
5
 
6
- Follow the instructions in ./workflow.md.
6
+ ## STOP read this entire file before doing anything
7
+
8
+ Sprintpilot is driven by a deterministic Node.js state machine at
9
+ `_Sprintpilot/bin/autopilot.js`. The LLM owns in-skill execution,
10
+ diagnosis, triage, and small-judgment decisions — not the flow.
11
+
12
+ Follow **`./workflow.orchestrator.md`** verbatim. Flow control lives in
13
+ `_Sprintpilot/bin/autopilot.js` (a Node CLI you call via `autopilot next`
14
+ / `autopilot record`). The orchestrator emits actions; you execute them.
15
+
16
+ ### Never improvise
17
+
18
+ - Never decide which BMad skill runs next yourself — the state machine
19
+ emits an `invoke_skill` action telling you.
20
+ - Never skip the `autopilot next` → `autopilot record` cycle. Even when
21
+ a step feels "obvious," route through the CLI so the ledger, verify,
22
+ and bookkeeping enforcement run.
23
+ - Do not search for `workflow.md` or reconstruct it from memory; do not
24
+ read cached BMad legacy patterns and apply them ahead of the
25
+ orchestrator's state machine.
26
+
27
+ `workflow.orchestrator.md` is the **sole authority** for the rest of the
28
+ session.