@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
@@ -0,0 +1,157 @@
1
+ // user-command-applier.js — apply validated UserCommands to runtime state.
2
+ //
3
+ // Pure function (state, profile, commands) → { newState, newProfile, sideEffects }
4
+ //
5
+ // The CLI edge calls user-commands.validate() first, then passes the
6
+ // validated commands here. The applier mutates runtime fields ONLY — it
7
+ // never touches sprint-status.yaml (that's BMad's domain).
8
+ //
9
+ // Adapt.js emits an `apply_user_commands` side effect; the CLI runs the
10
+ // validation and this applier, then re-emits nextAction with the new state.
11
+
12
+ 'use strict';
13
+
14
+ const { STATES } = require('./state-machine');
15
+
16
+ function applyOne(state, profile, cmd) {
17
+ const effects = [];
18
+ let newState = state;
19
+ let newProfile = profile;
20
+
21
+ switch (cmd.kind) {
22
+ case 'skip_story':
23
+ // Drop the current story; transition to next-story-start.
24
+ // The CLI is responsible for actually marking sprint-status; we
25
+ // record the intent here so the next nextAction picks up fresh.
26
+ newState = {
27
+ ...state,
28
+ phase:
29
+ profile.implementation_flow === 'quick' ? STATES.NANO_QUICK_DEV : STATES.CREATE_STORY,
30
+ story_key: null,
31
+ story_file_path: null,
32
+ ac_summary: null,
33
+ prior_diagnosis: null,
34
+ patch_findings: null,
35
+ tests_to_rerun: null,
36
+ retry_count_this_phase: 0,
37
+ verify_reject_count: 0,
38
+ consecutive_test_failures: 0,
39
+ };
40
+ effects.push({
41
+ kind: 'state_transition',
42
+ from: state.phase,
43
+ to: newState.phase,
44
+ reason: 'user_skip_story',
45
+ skipped_story: cmd.story_key,
46
+ });
47
+ break;
48
+
49
+ case 'abort_sprint':
50
+ newState = {
51
+ ...state,
52
+ phase: STATES.SPRINT_FINALIZE_PENDING,
53
+ sprint_is_complete: true,
54
+ };
55
+ effects.push({
56
+ kind: 'halt',
57
+ reason: 'user_abort_sprint',
58
+ details: cmd.reason || null,
59
+ });
60
+ break;
61
+
62
+ case 'force_continue':
63
+ // Clears verify-reject + retry counters so the orchestrator stops
64
+ // looping on a stuck transition. Phase is unchanged.
65
+ newState = {
66
+ ...state,
67
+ retry_count_this_phase: 0,
68
+ verify_reject_count: 0,
69
+ consecutive_test_failures: 0,
70
+ };
71
+ effects.push({
72
+ kind: 'state_transition',
73
+ from: state.phase,
74
+ to: state.phase,
75
+ reason: 'user_force_continue',
76
+ details: cmd.reason || null,
77
+ });
78
+ break;
79
+
80
+ case 'change_profile':
81
+ // Session-scoped profile change. The CLI MUST NOT write back to
82
+ // config.yaml. Per-profile orchestrator defaults are re-seeded but
83
+ // the rest of the typed Profile (retrospective_mode, etc.) is left
84
+ // alone unless the user explicitly re-runs validate-config.
85
+ newProfile = {
86
+ ...profile,
87
+ name: cmd.profile,
88
+ // Re-seed budgets from the orchestrator defaults table.
89
+ retry_budget_per_action: defaultRetryBudgetFor(cmd.profile),
90
+ verify_reject_budget: defaultVerifyBudgetFor(cmd.profile),
91
+ // Mark the change so audit can detect it.
92
+ changed_via_user_command: true,
93
+ };
94
+ effects.push({
95
+ kind: 'profile_escalated', // reuse the ledger kind
96
+ from: profile.name,
97
+ to: cmd.profile,
98
+ reason: 'user_change_profile',
99
+ });
100
+ break;
101
+
102
+ case 'pause':
103
+ // No state change. The CLI is expected to halt the loop and wait
104
+ // for the user to /sprint-autopilot-on again. Recorded for audit.
105
+ effects.push({
106
+ kind: 'halt',
107
+ reason: 'user_pause',
108
+ details: cmd.reason || null,
109
+ });
110
+ break;
111
+
112
+ case 'override_decision':
113
+ // We don't apply a state mutation. The CLI records this so a
114
+ // subsequent verify_override can reference DEC-id.
115
+ effects.push({
116
+ kind: 'state_transition',
117
+ from: state.phase,
118
+ to: state.phase,
119
+ reason: 'user_override_decision',
120
+ decision_id: cmd.decision_id,
121
+ new_value: cmd.new_value,
122
+ });
123
+ break;
124
+
125
+ default:
126
+ effects.push({ kind: 'state_transition', reason: 'unknown_user_command', cmd });
127
+ }
128
+
129
+ return { newState, newProfile, effects };
130
+ }
131
+
132
+ // Mirror of profile-rules.ORCHESTRATOR_DEFAULTS_BY_PROFILE — kept inline so
133
+ // the applier doesn't pull the whole profile-rules module in tight loops.
134
+ function defaultRetryBudgetFor(name) {
135
+ if (name === 'nano') return 1;
136
+ if (name === 'large') return 3;
137
+ return 2;
138
+ }
139
+ function defaultVerifyBudgetFor(name) {
140
+ if (name === 'nano') return 2;
141
+ return 3;
142
+ }
143
+
144
+ function apply(state, profile, commands) {
145
+ let s = state;
146
+ let p = profile;
147
+ const allEffects = [];
148
+ for (const cmd of commands || []) {
149
+ const r = applyOne(s, p, cmd);
150
+ s = r.newState;
151
+ p = r.newProfile;
152
+ for (const e of r.effects) allEffects.push(e);
153
+ }
154
+ return { newState: s, newProfile: p, sideEffects: allEffects };
155
+ }
156
+
157
+ module.exports = { apply, applyOne };
@@ -0,0 +1,115 @@
1
+ // user-commands.js — validate UserCommand payloads emitted via `user_input` signals.
2
+ //
3
+ // The LLM watches the host chat for user interjections and translates them
4
+ // into structured UserCommand objects. The orchestrator validates and
5
+ // applies them.
6
+ //
7
+ // Pure module. No I/O.
8
+ //
9
+ // Command kinds (initial set; new kinds added via additive PR):
10
+ // skip_story { story_key: string, reason?: string }
11
+ // abort_sprint { reason?: string }
12
+ // force_continue { reason?: string }
13
+ // override_decision { decision_id: string, new_value: string }
14
+ // change_profile { profile: 'nano'|'small'|'medium'|'large'|'legacy' }
15
+ // pause { reason?: string }
16
+ //
17
+ // Validation returns { ok: true, command } | { ok: false, errors: string[] }.
18
+
19
+ 'use strict';
20
+
21
+ const VALID_PROFILE_NAMES = ['nano', 'small', 'medium', 'large', 'legacy'];
22
+
23
+ const COMMAND_KINDS = [
24
+ 'skip_story',
25
+ 'abort_sprint',
26
+ 'force_continue',
27
+ 'override_decision',
28
+ 'change_profile',
29
+ 'pause',
30
+ ];
31
+
32
+ const STORY_KEY_RE = /^[A-Za-z0-9._-]{1,64}$/;
33
+ const DECISION_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
34
+
35
+ function isPlainObject(v) {
36
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
37
+ }
38
+
39
+ function nonEmptyString(v) {
40
+ return typeof v === 'string' && v.length > 0;
41
+ }
42
+
43
+ function validateOne(cmd) {
44
+ const errors = [];
45
+ if (!isPlainObject(cmd)) {
46
+ return { ok: false, errors: ['command is not an object'] };
47
+ }
48
+ if (!nonEmptyString(cmd.kind)) {
49
+ errors.push('missing kind');
50
+ return { ok: false, errors };
51
+ }
52
+ if (!COMMAND_KINDS.includes(cmd.kind)) {
53
+ errors.push(`unknown kind: ${cmd.kind}`);
54
+ return { ok: false, errors };
55
+ }
56
+
57
+ switch (cmd.kind) {
58
+ case 'skip_story': {
59
+ if (!nonEmptyString(cmd.story_key)) errors.push('skip_story.story_key required');
60
+ else if (!STORY_KEY_RE.test(cmd.story_key))
61
+ errors.push('skip_story.story_key must match [A-Za-z0-9._-]{1,64}');
62
+ if ('reason' in cmd && cmd.reason !== undefined && typeof cmd.reason !== 'string')
63
+ errors.push('skip_story.reason must be string when present');
64
+ break;
65
+ }
66
+ case 'abort_sprint':
67
+ case 'force_continue':
68
+ case 'pause': {
69
+ if ('reason' in cmd && cmd.reason !== undefined && typeof cmd.reason !== 'string')
70
+ errors.push(`${cmd.kind}.reason must be string when present`);
71
+ break;
72
+ }
73
+ case 'override_decision': {
74
+ if (!nonEmptyString(cmd.decision_id)) errors.push('override_decision.decision_id required');
75
+ else if (!DECISION_ID_RE.test(cmd.decision_id))
76
+ errors.push('override_decision.decision_id must match [A-Za-z0-9._-]{1,64}');
77
+ if (!nonEmptyString(cmd.new_value)) errors.push('override_decision.new_value required');
78
+ break;
79
+ }
80
+ case 'change_profile': {
81
+ if (!nonEmptyString(cmd.profile)) errors.push('change_profile.profile required');
82
+ else if (!VALID_PROFILE_NAMES.includes(cmd.profile))
83
+ errors.push(`change_profile.profile must be one of ${VALID_PROFILE_NAMES.join(',')}`);
84
+ break;
85
+ }
86
+ default:
87
+ errors.push(`unhandled kind: ${cmd.kind}`);
88
+ }
89
+
90
+ if (errors.length > 0) return { ok: false, errors };
91
+ return { ok: true, command: cmd };
92
+ }
93
+
94
+ // validate(commands) — accepts a single command or an array. Returns:
95
+ // { ok: true, commands: UserCommand[] } when every command validates
96
+ // { ok: false, errors: { index, errors: string[] }[] } otherwise
97
+ function validate(input) {
98
+ const list = Array.isArray(input) ? input : [input];
99
+ const valid = [];
100
+ const errors = [];
101
+ for (let i = 0; i < list.length; i += 1) {
102
+ const r = validateOne(list[i]);
103
+ if (r.ok) valid.push(r.command);
104
+ else errors.push({ index: i, errors: r.errors });
105
+ }
106
+ if (errors.length > 0) return { ok: false, errors };
107
+ return { ok: true, commands: valid };
108
+ }
109
+
110
+ module.exports = {
111
+ COMMAND_KINDS,
112
+ VALID_PROFILE_NAMES,
113
+ validate,
114
+ validateOne,
115
+ };
@@ -0,0 +1,397 @@
1
+ // verify.js — per-action verification table. Trust boundary on `success`.
2
+ //
3
+ // The LLM may claim success that isn't actually true (test file not written,
4
+ // AC not satisfied, etc.). verify.js inspects the world (filesystem +
5
+ // optional process exit codes from a runner callback) and decides if the
6
+ // `success` is structurally plausible.
7
+ //
8
+ // Returns { ok: boolean, issues: string[] }.
9
+ //
10
+ // All filesystem access goes through an injected `fs` so tests can pass a
11
+ // mock filesystem. Process invocation (running a test command) goes through
12
+ // an injected `runner` callback so the orchestrator can choose how to
13
+ // dispatch (synchronous spawn, async batch, etc.).
14
+ //
15
+ // `verify.js` is the structural complement to `adapt.js`:
16
+ // - adapt.js: how to react to a signal
17
+ // - verify.js: is the signal's claim plausible against the world
18
+
19
+ 'use strict';
20
+
21
+ const nodeFs = require('node:fs');
22
+ const nodePath = require('node:path');
23
+
24
+ const { STATES } = require('./state-machine');
25
+
26
+ function fileExists(fs, path) {
27
+ try {
28
+ fs.accessSync(path, fs.constants ? fs.constants.F_OK : 0);
29
+ return true;
30
+ } catch (_e) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function readFileSafe(fs, path) {
36
+ try {
37
+ return fs.readFileSync(path, 'utf8');
38
+ } catch (_e) {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function isNonEmptyArray(v) {
44
+ return Array.isArray(v) && v.length > 0;
45
+ }
46
+
47
+ function frontMatter(text) {
48
+ if (!text) return null;
49
+ const m = text.match(/^---\n([\s\S]*?)\n---/);
50
+ return m ? m[1] : null;
51
+ }
52
+
53
+ function escapeRe(s) {
54
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
+ }
56
+
57
+ // Extract a story's status from sprint-status.yaml without pulling in a
58
+ // full YAML parser. Supports both inline form (`<key>: <status>`) and
59
+ // block form (`<key>:\n status: <status>\n title: ...`).
60
+ function storyStatusFromSprintStatus(text, storyKey) {
61
+ if (!text || !storyKey) return null;
62
+ const k = escapeRe(storyKey);
63
+ // Block form first — has a `status:` line inside the indented block.
64
+ const blockRe = new RegExp(`^(\\s+)${k}:\\s*\\n((?:\\1\\s+[^\\n]+\\n)+)`, 'm');
65
+ const bm = text.match(blockRe);
66
+ if (bm) {
67
+ const inner = bm[2];
68
+ const sm = inner.match(/^\s+status:\s*["']?([\w-]+)["']?/m);
69
+ if (sm) return sm[1];
70
+ }
71
+ // Inline form: ` story-key: done` (status as scalar value).
72
+ const inlineRe = new RegExp(`^\\s+${k}:\\s*["']?([\\w-]+)["']?\\s*$`, 'm');
73
+ const im = text.match(inlineRe);
74
+ return im ? im[1] : null;
75
+ }
76
+
77
+ function readSprintStatus(fs, projectRoot) {
78
+ const p = nodePath.join(
79
+ projectRoot,
80
+ '_bmad-output',
81
+ 'implementation-artifacts',
82
+ 'sprint-status.yaml',
83
+ );
84
+ return readFileSafe(fs, p);
85
+ }
86
+
87
+ // Per-phase verifiers. Each receives (state, signalOutput, context) and
88
+ // returns { ok, issues[] }. `context` carries injected dependencies.
89
+ const VERIFIERS = {
90
+ [STATES.CREATE_STORY]: verifyCreateStory,
91
+ [STATES.CHECK_READINESS]: verifyCheckReadiness,
92
+ [STATES.DEV_RED]: verifyDevRed,
93
+ [STATES.DEV_GREEN]: verifyDevGreen,
94
+ [STATES.CODE_REVIEW]: verifyCodeReview,
95
+ [STATES.PATCH_APPLY]: verifyPatchApply,
96
+ [STATES.PATCH_RETEST]: verifyPatchRetest,
97
+ [STATES.STORY_DONE]: verifyStoryDone,
98
+ [STATES.EPIC_BOUNDARY_CHECK]: verifyEpicBoundary,
99
+ [STATES.RETROSPECTIVE]: verifyRetrospective,
100
+ [STATES.NANO_QUICK_DEV]: verifyNanoQuickDev,
101
+ };
102
+
103
+ function verify(state, signalOutput, context) {
104
+ if (!state || !state.phase) return { ok: false, issues: ['state.phase missing'] };
105
+ const fn = VERIFIERS[state.phase];
106
+ if (!fn) return { ok: true, issues: [] }; // unknown phase: defer to state machine
107
+ const ctx = {
108
+ fs: (context && context.fs) || nodeFs,
109
+ runner: (context && context.runner) || null,
110
+ projectRoot: (context && context.projectRoot) || '.',
111
+ augmented: (context && context.augmented) || null,
112
+ };
113
+ try {
114
+ return fn(state, signalOutput || {}, ctx);
115
+ } catch (e) {
116
+ return { ok: false, issues: [`verifier threw: ${e.message}`] };
117
+ }
118
+ }
119
+
120
+ function verifyCreateStory(state, _out, ctx) {
121
+ const issues = [];
122
+ if (!state.story_file_path) issues.push('story_file_path not set');
123
+ else if (!fileExists(ctx.fs, state.story_file_path))
124
+ issues.push(`story file missing: ${state.story_file_path}`);
125
+ else {
126
+ const text = readFileSafe(ctx.fs, state.story_file_path);
127
+ const fm = frontMatter(text);
128
+ if (!fm) issues.push('story file missing YAML front-matter');
129
+ // AC presence — look for "## Acceptance Criteria" section with at least one bullet.
130
+ if (text && !/##\s+Acceptance Criteria[\s\S]*?\n-\s+/.test(text)) {
131
+ issues.push('Acceptance Criteria section missing or empty');
132
+ }
133
+ // Tasks/Subtasks section with at least one task checkbox — required by
134
+ // BMad bookkeeping. `bmad-create-story` produces unchecked `[ ]`
135
+ // entries; `bmad-dev-story` flips them to `[x]`. If neither is present,
136
+ // dev-story will have nothing to check off.
137
+ if (text && !/##\s+Tasks(?:\s*\/\s*Subtasks)?[\s\S]*?(?:\[ \]|\[x\])/i.test(text)) {
138
+ issues.push(
139
+ 'Tasks (or Tasks/Subtasks) section with at least one `[ ]` or `[x]` checkbox missing',
140
+ );
141
+ }
142
+ }
143
+ return { ok: issues.length === 0, issues };
144
+ }
145
+
146
+ function verifyCheckReadiness(state, _out, ctx) {
147
+ const issues = [];
148
+ const text = state.story_file_path ? readFileSafe(ctx.fs, state.story_file_path) : null;
149
+ const fm = frontMatter(text);
150
+ if (!fm) {
151
+ issues.push('story front-matter missing — cannot verify readiness verdict');
152
+ } else if (!/readiness:\s*(true|false|ready|blocked)/i.test(fm)) {
153
+ issues.push('readiness verdict not present in front-matter');
154
+ }
155
+ return { ok: issues.length === 0, issues };
156
+ }
157
+
158
+ function verifyDevRed(state, out, ctx) {
159
+ const issues = [];
160
+ // 1. Test files claimed in output exist.
161
+ const testFiles = isNonEmptyArray(out.test_files) ? out.test_files : [];
162
+ if (testFiles.length === 0) issues.push('no test_files reported');
163
+ for (const f of testFiles) {
164
+ if (!fileExists(ctx.fs, f)) issues.push(`test file missing: ${f}`);
165
+ }
166
+ // 2. Run the tests via the injected runner; expect non-zero exit (RED).
167
+ if (ctx.runner) {
168
+ const result = ctx.runner({ phase: 'red', files: testFiles });
169
+ if (!result || typeof result.exit_code !== 'number') {
170
+ issues.push('runner did not report exit_code');
171
+ } else if (result.exit_code === 0) {
172
+ issues.push('tests passed on RED phase — expected at least one failure');
173
+ }
174
+ }
175
+ // 3. No source files mutated — LLM should have only added tests.
176
+ if (isNonEmptyArray(out.source_files_changed)) {
177
+ issues.push(
178
+ `source files changed in RED phase: ${out.source_files_changed.join(',')} — expected tests only`,
179
+ );
180
+ }
181
+ return { ok: issues.length === 0, issues };
182
+ }
183
+
184
+ function verifyDevGreen(state, out, ctx) {
185
+ const issues = [];
186
+ if (ctx.runner) {
187
+ const result = ctx.runner({ phase: 'green', files: out.test_files || [] });
188
+ if (!result || typeof result.exit_code !== 'number') {
189
+ issues.push('runner did not report exit_code');
190
+ } else if (result.exit_code !== 0) {
191
+ issues.push(`tests still failing on GREEN: exit ${result.exit_code}`);
192
+ } else if (typeof result.tests_run === 'number' && typeof out.tests_run === 'number') {
193
+ if (result.tests_run !== out.tests_run) {
194
+ issues.push(
195
+ `LLM reported ${out.tests_run} tests run but runner reported ${result.tests_run}`,
196
+ );
197
+ }
198
+ }
199
+ }
200
+ if (typeof out.tests_run !== 'number' || out.tests_run <= 0) {
201
+ issues.push('tests_run must be a positive number (per AGENTS.md test-result format)');
202
+ }
203
+ return { ok: issues.length === 0, issues };
204
+ }
205
+
206
+ function verifyCodeReview(state, out, ctx) {
207
+ const issues = [];
208
+ const reviewPath = nodePath.join(
209
+ ctx.projectRoot,
210
+ '_bmad-output',
211
+ 'reviews',
212
+ `${state.story_key || 'unknown'}.md`,
213
+ );
214
+ if (!fileExists(ctx.fs, reviewPath)) {
215
+ issues.push(`review artifact missing: ${reviewPath}`);
216
+ }
217
+ const findings = Array.isArray(out.findings) ? out.findings : null;
218
+ if (findings === null) {
219
+ issues.push('findings[] missing from output — code-review must produce a triage payload');
220
+ } else {
221
+ for (let i = 0; i < findings.length; i += 1) {
222
+ const f = findings[i];
223
+ if (!f || typeof f !== 'object') {
224
+ issues.push(`findings[${i}]: not an object`);
225
+ continue;
226
+ }
227
+ if (!f.id) issues.push(`findings[${i}].id required`);
228
+ if (!['block', 'patch', 'defer'].includes(f.action)) {
229
+ issues.push(`findings[${i}].action must be block|patch|defer`);
230
+ }
231
+ if (!f.rationale) issues.push(`findings[${i}].rationale required`);
232
+ }
233
+ }
234
+ return { ok: issues.length === 0, issues };
235
+ }
236
+
237
+ function verifyPatchApply(state, out, _ctx) {
238
+ const issues = [];
239
+ const expected = Array.isArray(state.patch_findings) ? state.patch_findings.map((f) => f.id) : [];
240
+ const applied = Array.isArray(out.applied_finding_ids) ? out.applied_finding_ids : [];
241
+ for (const id of expected) {
242
+ if (!applied.includes(id)) issues.push(`patch finding not applied: ${id}`);
243
+ }
244
+ if (out.commit_sha && typeof out.commit_sha !== 'string') {
245
+ issues.push('commit_sha must be a string when present');
246
+ }
247
+ return { ok: issues.length === 0, issues };
248
+ }
249
+
250
+ function verifyPatchRetest(state, out, ctx) {
251
+ const issues = [];
252
+ if (ctx.runner) {
253
+ const result = ctx.runner({
254
+ phase: 'rereview',
255
+ files: Array.isArray(state.tests_to_rerun) ? state.tests_to_rerun : out.test_files || [],
256
+ });
257
+ if (!result || typeof result.exit_code !== 'number') {
258
+ issues.push('runner did not report exit_code');
259
+ } else if (result.exit_code !== 0) {
260
+ issues.push(`tests failed after patch: exit ${result.exit_code}`);
261
+ }
262
+ }
263
+ if (typeof out.tests_run !== 'number' || out.tests_run <= 0) {
264
+ issues.push('tests_run must be a positive number');
265
+ }
266
+ return { ok: issues.length === 0, issues };
267
+ }
268
+
269
+ function verifyStoryDone(state, out, ctx) {
270
+ const issues = [];
271
+ if (!out.commit_sha) issues.push('commit_sha required');
272
+ if (!out.branch) issues.push('branch required');
273
+ if (out.story_key && state.story_key && out.story_key !== state.story_key) {
274
+ issues.push(`commit story_key mismatch: ${out.story_key} vs ${state.story_key}`);
275
+ }
276
+ // The orchestrator decorated this phase's git_op action with the planned
277
+ // argv steps (commit + push). Without this check, the LLM can run only
278
+ // `git commit` and report success — leaving the story branch unpushed.
279
+ // Confirmed live in greenfield e2e: signal had commit_sha+branch but
280
+ // origin/<branch> never appeared on remote.
281
+ if (out.git_steps_completed !== true) {
282
+ issues.push(
283
+ 'git_steps_completed must be true — set to true ONLY after every step in action.steps (git add, commit, push) exited 0. Skipping git push is the most common cause.',
284
+ );
285
+ }
286
+ // BMad bookkeeping: sprint-status.yaml MUST record this story as `done`.
287
+ // Without this check, the LLM can claim STORY_DONE while sprint-status
288
+ // still shows the story as `backlog`/`in-progress`, which means the next
289
+ // story selection picks the wrong work item.
290
+ if (state.story_key) {
291
+ const sprintStatus = readSprintStatus(ctx.fs, ctx.projectRoot);
292
+ if (!sprintStatus) {
293
+ issues.push('sprint-status.yaml missing — required to mark story done');
294
+ } else {
295
+ const status = storyStatusFromSprintStatus(sprintStatus, state.story_key);
296
+ if (status === null) {
297
+ issues.push(
298
+ `sprint-status.yaml has no entry for story ${state.story_key} — did create-story register it?`,
299
+ );
300
+ } else if (status !== 'done') {
301
+ issues.push(
302
+ `sprint-status.yaml shows story ${state.story_key} as '${status}', expected 'done'`,
303
+ );
304
+ }
305
+ }
306
+ }
307
+ // BMad bookkeeping: story file's task checkboxes must all be checked.
308
+ if (state.story_file_path) {
309
+ const text = readFileSafe(ctx.fs, state.story_file_path);
310
+ if (text) {
311
+ const unchecked = (text.match(/\[ \]/g) || []).length;
312
+ if (unchecked > 0) {
313
+ issues.push(
314
+ `story file has ${unchecked} unchecked task box(es) remaining — dev-story should flip all to [x]`,
315
+ );
316
+ }
317
+ }
318
+ }
319
+ return { ok: issues.length === 0, issues };
320
+ }
321
+
322
+ function verifyEpicBoundary(_state, _out, _ctx) {
323
+ // Structural check only — no artifact expected.
324
+ return { ok: true, issues: [] };
325
+ }
326
+
327
+ function verifyRetrospective(state, _out, ctx) {
328
+ const issues = [];
329
+ const epicKey = state.current_epic || 'unknown';
330
+ const retroPath = nodePath.join(
331
+ ctx.projectRoot,
332
+ '_bmad-output',
333
+ 'retrospectives',
334
+ `${epicKey}.md`,
335
+ );
336
+ if (!fileExists(ctx.fs, retroPath)) issues.push(`retro artifact missing: ${retroPath}`);
337
+ return { ok: issues.length === 0, issues };
338
+ }
339
+
340
+ function verifyNanoQuickDev(state, out, ctx) {
341
+ const issues = [];
342
+ if (typeof out.tests_run !== 'number' || out.tests_run <= 0) {
343
+ issues.push('tests_run must be a positive number');
344
+ }
345
+ if (typeof out.tests_failed !== 'number') {
346
+ issues.push('tests_failed required (number; 0 for clean)');
347
+ }
348
+ if (!out.commit_sha) issues.push('commit_sha required');
349
+ // BMad bookkeeping (nano edition): sprint-status.yaml MUST record the
350
+ // story as `done` after a successful quick-dev cycle. Same enforcement
351
+ // as the full-flow STORY_DONE phase.
352
+ if (state.story_key) {
353
+ const sprintStatus = readSprintStatus(ctx.fs, ctx.projectRoot);
354
+ if (!sprintStatus) {
355
+ issues.push('sprint-status.yaml missing — required to mark story done');
356
+ } else {
357
+ const status = storyStatusFromSprintStatus(sprintStatus, state.story_key);
358
+ if (status === null) {
359
+ issues.push(`sprint-status.yaml has no entry for story ${state.story_key}`);
360
+ } else if (status !== 'done') {
361
+ issues.push(
362
+ `sprint-status.yaml shows story ${state.story_key} as '${status}', expected 'done'`,
363
+ );
364
+ }
365
+ }
366
+ }
367
+ return { ok: issues.length === 0, issues };
368
+ }
369
+
370
+ // verifyWithOverride — used when the LLM sends a verify_override signal.
371
+ // Re-runs verification with augmented expectations (from signal.evidence).
372
+ // Currently supports `expected_paths` — additional files the LLM claims
373
+ // satisfy the structural requirement that verify.js was looking for.
374
+ function verifyWithOverride(state, signalOutput, context, override) {
375
+ const augmented = {
376
+ ...context,
377
+ augmented: override || null,
378
+ };
379
+ const base = verify(state, signalOutput, augmented);
380
+ if (!override || !override.expected_paths) return base;
381
+ // For now, the only augmentation is: if a test file the LLM renamed
382
+ // exists in expected_paths, treat 'test file missing' issues as satisfied
383
+ // when at least one of the expected_paths exists.
384
+ const fs = (context && context.fs) || nodeFs;
385
+ const someExists = override.expected_paths.some((p) => fileExists(fs, p));
386
+ if (someExists) {
387
+ const filtered = (base.issues || []).filter((i) => !/test file missing/.test(i));
388
+ return { ok: filtered.length === 0, issues: filtered };
389
+ }
390
+ return base;
391
+ }
392
+
393
+ module.exports = {
394
+ verify,
395
+ verifyWithOverride,
396
+ VERIFIERS,
397
+ };
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.0.10
3
+ version: 2.1.0
4
4
  description: Sprintpilot — autopilot and multi-agent addon for BMad Method (git workflow, parallel agents, autonomous story execution)
5
5
  bmad_compatibility: ">=6.2.0"
6
6
  modules: