@ikunin/sprintpilot 2.2.16 → 2.2.18

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.
@@ -95,6 +95,113 @@ function readSprintStatus(fs, projectRoot) {
95
95
  return readFileSafe(fs, p);
96
96
  }
97
97
 
98
+ // Auto-detect test files added/modified since the story branch started.
99
+ // Used by verifyDevRed when the LLM forgets to echo `test_files` in
100
+ // signal.output — a recurring user pain point: the LLM did the work but
101
+ // signaled `success` with empty output, causing the verifier to halt with
102
+ // "no test_files reported", retry budget exhausted, session dies.
103
+ //
104
+ // Lists git diff vs base-branch + untracked files, filters to test-shaped
105
+ // paths via TEST_FILE_PATTERNS. Returns [] on any error so the caller
106
+ // falls through to the strict rejection rather than silently accepting.
107
+ const TEST_FILE_PATTERNS = [
108
+ /(^|\/)[^/]+\.test\.(?:[mc]?jsx?|[mc]?tsx?)$/i,
109
+ /(^|\/)[^/]+\.spec\.(?:[mc]?jsx?|[mc]?tsx?)$/i,
110
+ /(^|\/)test_[^/]+\.py$/i,
111
+ /(^|\/)[^/]+_test\.py$/i,
112
+ /(^|\/)[^/]+_test\.go$/i,
113
+ /(^|\/)tests?\/[^/]+\.rs$/i,
114
+ /(^|\/)[^/]+Tests?\.swift$/i,
115
+ /(^|\/)[^/]+Test\.(?:kt|java)$/i,
116
+ /(^|\/)[^/]+_test\.rb$/i,
117
+ /(^|\/)[^/]+_spec\.rb$/i,
118
+ ];
119
+
120
+ function looksLikeTestFile(p) {
121
+ return TEST_FILE_PATTERNS.some((re) => re.test(p));
122
+ }
123
+
124
+ function autoDetectTestFiles(ctx, baseBranch) {
125
+ if (!ctx || !ctx.projectRoot) return [];
126
+ const projectRoot = ctx.projectRoot;
127
+ const NUL = String.fromCharCode(0);
128
+ const base = baseBranch || 'main';
129
+ const cp = require('node:child_process');
130
+ function runGit(extra) {
131
+ try {
132
+ return cp.execFileSync(
133
+ 'git',
134
+ ['-C', projectRoot, ...extra],
135
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000 },
136
+ );
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ // Prefer base...HEAD; fall back to a 5-commit window if origin/<base>
142
+ // isn't fetched. -z separates entries with NUL so filenames with spaces
143
+ // survive intact.
144
+ let raw = runGit(['diff', '--name-only', '--no-renames', '-z', base + '...HEAD']);
145
+ if (raw === null) raw = runGit(['diff', '--name-only', '--no-renames', '-z', 'HEAD~5..HEAD']);
146
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z']);
147
+ const parts = [];
148
+ for (const buf of [raw, untracked]) {
149
+ if (!buf) continue;
150
+ for (const p of buf.split(NUL)) {
151
+ const t = p.trim();
152
+ if (t) parts.push(t);
153
+ }
154
+ }
155
+ const seen = new Set();
156
+ const out = [];
157
+ for (const p of parts) {
158
+ if (!looksLikeTestFile(p)) continue;
159
+ const abs = nodePath.isAbsolute(p) ? p : nodePath.join(projectRoot, p);
160
+ if (seen.has(abs)) continue;
161
+ seen.add(abs);
162
+ out.push(abs);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ // Probe the underlying git state to confirm a STORY_DONE signal whose
168
+ // `git_steps_completed` flag was omitted. Returns true iff:
169
+ // - commit_sha resolves locally (git cat-file -e <sha>)
170
+ // - origin/<branch> resolves to the same sha (git ls-remote)
171
+ //
172
+ // Both checks must pass; either alone is insufficient (local commit
173
+ // without push, or remote pointing at a different commit, means the
174
+ // story isn't really done).
175
+ //
176
+ // Returns false on any error / missing tooling so the caller falls
177
+ // through to the strict rejection.
178
+ function verifyGitStepsViaProbe(out, ctx) {
179
+ if (!ctx || !ctx.projectRoot) return false;
180
+ if (!out || !out.commit_sha || !out.branch) return false;
181
+ const projectRoot = ctx.projectRoot;
182
+ const cp = require('node:child_process');
183
+ function runGit(args) {
184
+ try {
185
+ return cp.execFileSync(
186
+ 'git',
187
+ ['-C', projectRoot, ...args],
188
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000 },
189
+ );
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ // Local commit exists?
195
+ const local = runGit(['cat-file', '-e', out.commit_sha]);
196
+ if (local === null) return false;
197
+ // Remote tracks the same sha? -h restricts to refs/heads/<branch>.
198
+ const remote = runGit(['ls-remote', '--heads', 'origin', out.branch]);
199
+ if (!remote) return false;
200
+ // ls-remote output is "<sha>\trefs/heads/<branch>\n"
201
+ const remoteSha = remote.split(/\s+/)[0];
202
+ return typeof remoteSha === 'string' && remoteSha === out.commit_sha;
203
+ }
204
+
98
205
  // Per-phase verifiers. Each receives (state, signalOutput, context) and
99
206
  // returns { ok, issues[] }. `context` carries injected dependencies.
100
207
  const VERIFIERS = {
@@ -199,11 +306,30 @@ function verifyCheckReadiness(state, _out, ctx) {
199
306
 
200
307
  function verifyDevRed(state, out, ctx) {
201
308
  const issues = [];
202
- // 1. Test files claimed in output exist.
203
- const testFiles = isNonEmptyArray(out.test_files) ? out.test_files : [];
309
+ // 1. Test files claimed in output exist. If the LLM omitted test_files
310
+ // (a recurring failure mode), auto-detect from git diff / untracked
311
+ // files in the project tree so the verifier doesn't halt on a
312
+ // cosmetic signaling gap. Detected paths flow through the same
313
+ // fileExists check as LLM-supplied paths.
314
+ let testFiles = isNonEmptyArray(out.test_files) ? out.test_files : [];
315
+ let autodetected = false;
316
+ if (testFiles.length === 0 && ctx && ctx.projectRoot) {
317
+ const detected = autoDetectTestFiles(ctx, state && state.base_branch);
318
+ if (detected.length > 0) {
319
+ testFiles = detected;
320
+ autodetected = true;
321
+ }
322
+ }
204
323
  if (testFiles.length === 0) issues.push('no test_files reported');
324
+ // Resolve relative paths against projectRoot. LLM-supplied test_files
325
+ // are often relative like "apps/gateway/tests/x.test.ts" but the
326
+ // verifier runs from wherever cmdRecord was invoked. Without the
327
+ // resolve, fileExists checks against process.cwd() and reports
328
+ // "test file missing" for paths that actually exist.
205
329
  for (const f of testFiles) {
206
- if (!fileExists(ctx.fs, f)) issues.push(`test file missing: ${f}`);
330
+ const resolved =
331
+ nodePath.isAbsolute(f) || !ctx.projectRoot ? f : nodePath.join(ctx.projectRoot, f);
332
+ if (!fileExists(ctx.fs, resolved)) issues.push(`test file missing: ${f}`);
207
333
  }
208
334
  // 2. Run the tests via the injected runner; expect non-zero exit (RED).
209
335
  if (ctx.runner) {
@@ -220,7 +346,9 @@ function verifyDevRed(state, out, ctx) {
220
346
  `source files changed in RED phase: ${out.source_files_changed.join(',')} — expected tests only`,
221
347
  );
222
348
  }
223
- return { ok: issues.length === 0, issues };
349
+ const result = { ok: issues.length === 0, issues };
350
+ if (autodetected) result.autodetected_test_files = testFiles;
351
+ return result;
224
352
  }
225
353
 
226
354
  function verifyDevGreen(state, out, ctx) {
@@ -247,14 +375,44 @@ function verifyDevGreen(state, out, ctx) {
247
375
 
248
376
  function verifyCodeReview(state, out, ctx) {
249
377
  const issues = [];
250
- const reviewPath = nodePath.join(
378
+ // bmad-code-review (.claude/skills/bmad-code-review/steps/step-04-present.md)
379
+ // writes findings as a "### Review Findings" subsection INSIDE the story
380
+ // file's Tasks/Subtasks block — NOT a separate _bmad-output/reviews/<key>.md.
381
+ // The pre-2.2.17 check for that file rejected every real run because the
382
+ // skill never creates one (recurring user pain: "review artifact missing:
383
+ // <path>" halts).
384
+ //
385
+ // Accept any of:
386
+ // - story file contains a `### Review Findings` section
387
+ // - legacy `_bmad-output/reviews/<key>.md` exists (older repos)
388
+ // - legacy `_bmad-output/implementation-artifacts/code-review-<key>.md` exists
389
+ // Reject only when NONE of the above exist AND the LLM didn't supply
390
+ // findings[] inline.
391
+ const storyKey = state.story_key || 'unknown';
392
+ const reviewLegacy = nodePath.join(
251
393
  ctx.projectRoot,
252
394
  '_bmad-output',
253
395
  'reviews',
254
- `${state.story_key || 'unknown'}.md`,
396
+ `${storyKey}.md`,
397
+ );
398
+ const reviewArtifact = nodePath.join(
399
+ ctx.projectRoot,
400
+ '_bmad-output',
401
+ 'implementation-artifacts',
402
+ `code-review-${storyKey}.md`,
255
403
  );
256
- if (!fileExists(ctx.fs, reviewPath)) {
257
- issues.push(`review artifact missing: ${reviewPath}`);
404
+ const storyFile = state.story_file_path;
405
+ let foundReview = false;
406
+ if (fileExists(ctx.fs, reviewLegacy) || fileExists(ctx.fs, reviewArtifact)) {
407
+ foundReview = true;
408
+ } else if (storyFile) {
409
+ const text = readFileSafe(ctx.fs, storyFile);
410
+ if (text && /^#{2,4}\s+Review Findings\b/m.test(text)) foundReview = true;
411
+ }
412
+ if (!foundReview) {
413
+ issues.push(
414
+ `review artifact missing: expected one of (a) "### Review Findings" section in ${storyFile || '<story file>'}, (b) ${reviewLegacy}, or (c) ${reviewArtifact}`,
415
+ );
258
416
  }
259
417
  const findings = Array.isArray(out.findings) ? out.findings : null;
260
418
  if (findings === null) {
@@ -320,10 +478,19 @@ function verifyStoryDone(state, out, ctx) {
320
478
  // `git commit` and report success — leaving the story branch unpushed.
321
479
  // Confirmed live in greenfield e2e: signal had commit_sha+branch but
322
480
  // origin/<branch> never appeared on remote.
481
+ //
482
+ // Recovery path (recurring user pain): the LLM did the work but forgot
483
+ // to echo `git_steps_completed: true`. Probe the underlying git state —
484
+ // if the commit_sha exists locally AND origin/<branch> resolves to it,
485
+ // accept the signal. The full audit trail is in the ledger via the
486
+ // verify_result entry, so a false-positive auto-accept stays observable.
323
487
  if (out.git_steps_completed !== true) {
324
- issues.push(
325
- '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.',
326
- );
488
+ const autoConfirmed = verifyGitStepsViaProbe(out, ctx);
489
+ if (!autoConfirmed) {
490
+ issues.push(
491
+ '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.',
492
+ );
493
+ }
327
494
  }
328
495
  // BMad bookkeeping: sprint-status.yaml MUST record this story as `done`.
329
496
  // Without this check, the LLM can claim STORY_DONE while sprint-status
@@ -424,7 +591,11 @@ function verifyWithOverride(state, signalOutput, context, override) {
424
591
  // exists in expected_paths, treat 'test file missing' issues as satisfied
425
592
  // when at least one of the expected_paths exists.
426
593
  const fs = (context && context.fs) || nodeFs;
427
- const someExists = override.expected_paths.some((p) => fileExists(fs, p));
594
+ const root = context && context.projectRoot;
595
+ const someExists = override.expected_paths.some((p) => {
596
+ const resolved = nodePath.isAbsolute(p) || !root ? p : nodePath.join(root, p);
597
+ return fileExists(fs, resolved);
598
+ });
428
599
  if (someExists) {
429
600
  const filtered = (base.issues || []).filter((i) => !/test file missing/.test(i));
430
601
  return { ok: filtered.length === 0, issues: filtered };
@@ -1,6 +1,6 @@
1
1
  addon:
2
2
  name: sprintpilot
3
- version: 2.2.16
3
+ version: 2.2.18
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikunin/sprintpilot",
3
- "version": "2.2.16",
3
+ "version": "2.2.18",
4
4
  "description": "Sprintpilot — autopilot and multi-agent addon for BMad Method v6: git workflow, parallel agents, autonomous story execution",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {