@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.
- package/README.md +245 -10
- package/_Sprintpilot/Sprintpilot.md +1 -1
- package/_Sprintpilot/bin/autopilot.js +581 -0
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
- package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
- package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
- package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
- package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
- package/_Sprintpilot/lib/orchestrator/land.js +155 -0
- package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
- package/_Sprintpilot/lib/orchestrator/report.js +95 -0
- package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
- package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +26 -0
- package/_Sprintpilot/scripts/agent-adapter.js +4 -5
- package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
- package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
- package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
- package/_Sprintpilot/scripts/land-this-pr.js +110 -0
- package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
- package/_Sprintpilot/scripts/log-timing.js +12 -3
- package/_Sprintpilot/scripts/merge-shards.js +32 -12
- package/_Sprintpilot/scripts/post-green-gates.js +187 -0
- package/_Sprintpilot/scripts/preflight-merge.js +2 -1
- package/_Sprintpilot/scripts/resolve-dag.js +3 -1
- package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
- package/_Sprintpilot/scripts/state-shard.js +8 -1
- package/_Sprintpilot/scripts/summarize-timings.js +30 -12
- package/_Sprintpilot/scripts/with-retry.js +17 -5
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
- package/lib/core/update-check.js +11 -1
- package/package.json +1 -1
- 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
|
+
};
|