@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikunin/sprintpilot",
|
|
3
|
-
"version": "2.2.
|
|
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": {
|