@sandrinio/vbounce 1.6.0 → 1.7.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 +108 -18
- package/bin/vbounce.mjs +291 -146
- package/brains/AGENTS.md +5 -5
- package/brains/CHANGELOG.md +88 -1
- package/brains/CLAUDE.md +22 -17
- package/brains/GEMINI.md +40 -4
- package/brains/SETUP.md +11 -5
- package/brains/claude-agents/architect.md +13 -6
- package/brains/claude-agents/developer.md +2 -2
- package/brains/claude-agents/qa.md +16 -7
- package/brains/copilot/copilot-instructions.md +49 -0
- package/brains/cursor-rules/vbounce-process.mdc +2 -2
- package/brains/windsurf/.windsurfrules +30 -0
- package/package.json +2 -4
- package/scripts/close_sprint.mjs +94 -0
- package/scripts/complete_story.mjs +113 -0
- package/scripts/doctor.mjs +144 -0
- package/scripts/init_sprint.mjs +121 -0
- package/scripts/prep_arch_context.mjs +178 -0
- package/scripts/prep_qa_context.mjs +134 -0
- package/scripts/prep_sprint_context.mjs +118 -0
- package/scripts/prep_sprint_summary.mjs +154 -0
- package/scripts/sprint_trends.mjs +160 -0
- package/scripts/suggest_improvements.mjs +200 -0
- package/scripts/update_state.mjs +132 -0
- package/scripts/validate_bounce_readiness.mjs +125 -0
- package/scripts/validate_report.mjs +39 -2
- package/scripts/validate_sprint_plan.mjs +117 -0
- package/scripts/validate_state.mjs +99 -0
- package/skills/agent-team/SKILL.md +35 -17
- package/skills/agent-team/references/cleanup.md +42 -0
- package/skills/agent-team/references/delivery-sync.md +43 -0
- package/skills/agent-team/references/git-strategy.md +52 -0
- package/skills/agent-team/references/mid-sprint-triage.md +71 -0
- package/skills/agent-team/references/report-naming.md +34 -0
- package/skills/doc-manager/SKILL.md +5 -4
- package/skills/improve/SKILL.md +1 -1
- package/skills/lesson/SKILL.md +23 -0
- package/templates/delivery_plan.md +1 -1
- package/templates/hotfix.md +1 -1
- package/templates/sprint.md +65 -13
- package/templates/sprint_report.md +8 -1
- package/templates/story.md +1 -1
- package/scripts/pre_bounce_sync.sh +0 -37
- package/scripts/vbounce_ask.mjs +0 -98
- package/scripts/vbounce_index.mjs +0 -184
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* suggest_improvements.mjs
|
|
5
|
+
* Generates improvement suggestions from sprint trends + lessons.
|
|
6
|
+
* Overwrites (not appends) to prevent stale suggestion accumulation.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ./scripts/suggest_improvements.mjs S-05
|
|
10
|
+
*
|
|
11
|
+
* Output: .bounce/improvement-suggestions.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
20
|
+
|
|
21
|
+
const sprintId = process.argv[2];
|
|
22
|
+
if (!sprintId) {
|
|
23
|
+
console.error('Usage: suggest_improvements.mjs S-XX');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const today = new Date().toISOString().split('T')[0];
|
|
28
|
+
|
|
29
|
+
// 1. Read trends if available
|
|
30
|
+
const trendsFile = path.join(ROOT, '.bounce', 'trends.md');
|
|
31
|
+
let trendsContent = null;
|
|
32
|
+
if (fs.existsSync(trendsFile)) {
|
|
33
|
+
trendsContent = fs.readFileSync(trendsFile, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Read LESSONS.md
|
|
37
|
+
const lessonsFile = path.join(ROOT, 'LESSONS.md');
|
|
38
|
+
let lessonCount = 0;
|
|
39
|
+
let oldLessons = [];
|
|
40
|
+
if (fs.existsSync(lessonsFile)) {
|
|
41
|
+
const lines = fs.readFileSync(lessonsFile, 'utf8').split('\n');
|
|
42
|
+
// Count lessons by counting ### entries
|
|
43
|
+
const lessonEntries = lines.filter(l => /^###\s+\[\d{4}-\d{2}-\d{2}\]/.test(l));
|
|
44
|
+
lessonCount = lessonEntries.length;
|
|
45
|
+
|
|
46
|
+
// Flag lessons older than 90 days
|
|
47
|
+
const cutoff = new Date();
|
|
48
|
+
cutoff.setDate(cutoff.getDate() - 90);
|
|
49
|
+
oldLessons = lessonEntries.filter(entry => {
|
|
50
|
+
const dateMatch = entry.match(/\[(\d{4}-\d{2}-\d{2})\]/);
|
|
51
|
+
if (dateMatch) {
|
|
52
|
+
return new Date(dateMatch[1]) < cutoff;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 3. Read improvement-log for rejected items (to avoid re-suggesting)
|
|
59
|
+
const improvementLog = path.join(ROOT, '.bounce', 'improvement-log.md');
|
|
60
|
+
let rejectedItems = [];
|
|
61
|
+
if (fs.existsSync(improvementLog)) {
|
|
62
|
+
const logContent = fs.readFileSync(improvementLog, 'utf8');
|
|
63
|
+
// Simple extraction: look for table rows in "Rejected" section
|
|
64
|
+
const rejectedMatch = logContent.match(/## Rejected\n[\s\S]*?(?=\n## |$)/);
|
|
65
|
+
if (rejectedMatch) {
|
|
66
|
+
rejectedItems = rejectedMatch[0].split('\n')
|
|
67
|
+
.filter(l => l.startsWith('|') && !l.startsWith('| Sprint'))
|
|
68
|
+
.map(l => l.split('|')[2]?.trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 4. Parse sprint stats from trends
|
|
74
|
+
let lastSprintStats = null;
|
|
75
|
+
if (trendsContent) {
|
|
76
|
+
const rows = trendsContent.split('\n').filter(l => l.match(/^\| S-\d{2} \|/));
|
|
77
|
+
if (rows.length > 0) {
|
|
78
|
+
const lastRow = rows[rows.length - 1].split('|').map(s => s.trim()).filter(Boolean);
|
|
79
|
+
lastSprintStats = {
|
|
80
|
+
sprintId: lastRow[0],
|
|
81
|
+
stories: parseInt(lastRow[1]) || 0,
|
|
82
|
+
firstPassRate: parseInt(lastRow[2]) || 100,
|
|
83
|
+
avgBounces: parseFloat(lastRow[3]) || 0,
|
|
84
|
+
avgTax: parseFloat(lastRow[4]) || 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const suggestions = [];
|
|
90
|
+
let itemNum = 1;
|
|
91
|
+
|
|
92
|
+
// 5. Generate suggestions based on data
|
|
93
|
+
if (lastSprintStats) {
|
|
94
|
+
if (lastSprintStats.firstPassRate < 80) {
|
|
95
|
+
suggestions.push({
|
|
96
|
+
num: itemNum++,
|
|
97
|
+
category: 'Process',
|
|
98
|
+
title: `Low first-pass rate (${lastSprintStats.firstPassRate}%)`,
|
|
99
|
+
detail: `First-pass rate was below 80% in ${lastSprintStats.sprintId}. This suggests spec ambiguity or insufficient context packs.`,
|
|
100
|
+
recommendation: 'Add spec quality gate to `validate_bounce_readiness.mjs`: check Story §1 word count > 50 and §2 has ≥ 2 Gherkin scenarios.',
|
|
101
|
+
target: 'scripts/validate_bounce_readiness.mjs',
|
|
102
|
+
effort: 'Low',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (lastSprintStats.avgTax > 10) {
|
|
107
|
+
suggestions.push({
|
|
108
|
+
num: itemNum++,
|
|
109
|
+
category: 'Process',
|
|
110
|
+
title: `High correction tax (${lastSprintStats.avgTax}% average)`,
|
|
111
|
+
detail: 'Average correction tax exceeded 10%, indicating significant human intervention was needed.',
|
|
112
|
+
recommendation: 'Auto-flag stories with more than 3 files expected in Sprint Plan §2 Risk Flags. Consider splitting before bouncing.',
|
|
113
|
+
target: 'skills/agent-team/SKILL.md Step 1',
|
|
114
|
+
effort: 'Low',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (lastSprintStats.avgBounces > 0.5) {
|
|
119
|
+
suggestions.push({
|
|
120
|
+
num: itemNum++,
|
|
121
|
+
category: 'Process',
|
|
122
|
+
title: `High bounce rate (${lastSprintStats.avgBounces} avg per story)`,
|
|
123
|
+
detail: 'Run `vbounce trends` to see root cause breakdown and identify recurring patterns.',
|
|
124
|
+
recommendation: 'Review root_cause field in archived QA/Arch FAIL reports to identify systemic issues.',
|
|
125
|
+
target: 'scripts/sprint_trends.mjs',
|
|
126
|
+
effort: 'Low',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Old lessons suggestion
|
|
132
|
+
if (oldLessons.length > 0) {
|
|
133
|
+
const notRejected = oldLessons.filter(l => !rejectedItems.some(r => l.includes(r)));
|
|
134
|
+
if (notRejected.length > 0) {
|
|
135
|
+
suggestions.push({
|
|
136
|
+
num: itemNum++,
|
|
137
|
+
category: 'Framework',
|
|
138
|
+
title: `${notRejected.length} lessons older than 90 days`,
|
|
139
|
+
detail: notRejected.map(l => ` - ${l}`).join('\n'),
|
|
140
|
+
recommendation: 'Review these lessons. Lessons not triggered in 3+ sprints should be archived to LESSONS_ARCHIVE.md. Lessons proven over 3+ sprints should be graduated to agent configs.',
|
|
141
|
+
target: 'LESSONS.md',
|
|
142
|
+
effort: 'Trivial',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// General framework suggestions
|
|
148
|
+
suggestions.push({
|
|
149
|
+
num: itemNum++,
|
|
150
|
+
category: 'Framework',
|
|
151
|
+
title: 'Review lesson graduation candidates',
|
|
152
|
+
detail: `You have ${lessonCount} lessons in LESSONS.md. Lessons proven over 3+ sprints should graduate to permanent agent config rules.`,
|
|
153
|
+
recommendation: 'Run a review: which lessons have prevented recurrences for 3+ sprints? Graduate those to `.claude/agents/*.md` or `brains/claude-agents/*.md`.',
|
|
154
|
+
target: 'LESSONS.md + brains/claude-agents/',
|
|
155
|
+
effort: 'Low',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
suggestions.push({
|
|
159
|
+
num: itemNum++,
|
|
160
|
+
category: 'Health',
|
|
161
|
+
title: 'Run vbounce doctor',
|
|
162
|
+
detail: 'Verify the V-Bounce OS installation is healthy after this sprint.',
|
|
163
|
+
recommendation: 'Run: `vbounce doctor` — checks brain files, templates, scripts, state.json validity.',
|
|
164
|
+
target: 'scripts/doctor.mjs',
|
|
165
|
+
effort: 'Trivial',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// 6. Format output
|
|
169
|
+
const suggestionBlocks = suggestions.map(s => {
|
|
170
|
+
const rejectedNote = rejectedItems.some(r => s.title.includes(r)) ? '\n> ⚠ This was previously rejected — skipping.' : '';
|
|
171
|
+
return `### ${s.num}. [${s.category}] ${s.title}${rejectedNote}
|
|
172
|
+
${s.detail}
|
|
173
|
+
|
|
174
|
+
**Recommendation:** ${s.recommendation}
|
|
175
|
+
**Target:** \`${s.target}\`
|
|
176
|
+
**Effort:** ${s.effort}`;
|
|
177
|
+
}).join('\n\n---\n\n');
|
|
178
|
+
|
|
179
|
+
const output = [
|
|
180
|
+
`# Improvement Suggestions (post ${sprintId})`,
|
|
181
|
+
`> Generated: ${today}. Review each item. Approved items are applied by the Lead at sprint boundary.`,
|
|
182
|
+
`> Rejected items go to \`.bounce/improvement-log.md\` with reason.`,
|
|
183
|
+
`> Applied items go to \`.bounce/improvement-log.md\` under Applied.`,
|
|
184
|
+
'',
|
|
185
|
+
suggestionBlocks || '_No suggestions generated — all metrics look healthy!_',
|
|
186
|
+
'',
|
|
187
|
+
'---',
|
|
188
|
+
'',
|
|
189
|
+
`## How to Apply`,
|
|
190
|
+
`- **Approve** → Lead applies change, records in \`.bounce/improvement-log.md\` under Applied`,
|
|
191
|
+
`- **Reject** → Record in \`.bounce/improvement-log.md\` under Rejected with reason`,
|
|
192
|
+
`- **Defer** → Record in \`.bounce/improvement-log.md\` under Deferred`,
|
|
193
|
+
'',
|
|
194
|
+
`> Framework changes (brains/, skills/, templates/) are applied at sprint boundaries only — never mid-sprint.`,
|
|
195
|
+
].join('\n');
|
|
196
|
+
|
|
197
|
+
const outputFile = path.join(ROOT, '.bounce', 'improvement-suggestions.md');
|
|
198
|
+
fs.writeFileSync(outputFile, output); // overwrite, not append
|
|
199
|
+
console.log(`✓ Improvement suggestions written to .bounce/improvement-suggestions.md`);
|
|
200
|
+
console.log(` ${suggestions.length} suggestion(s) generated`);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* update_state.mjs
|
|
5
|
+
* Updates .bounce/state.json atomically at every V-Bounce state transition.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/update_state.mjs STORY-005-02 "QA Passed"
|
|
9
|
+
* ./scripts/update_state.mjs STORY-005-02 --qa-bounce
|
|
10
|
+
* ./scripts/update_state.mjs STORY-005-02 --arch-bounce
|
|
11
|
+
* ./scripts/update_state.mjs --set-phase "Phase 3"
|
|
12
|
+
* ./scripts/update_state.mjs --set-action "QA FAIL on STORY-005-02, bouncing back to Dev"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import { validateState } from './validate_state.mjs';
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
22
|
+
const STATE_FILE = path.join(ROOT, '.bounce', 'state.json');
|
|
23
|
+
|
|
24
|
+
const VALID_STATES = [
|
|
25
|
+
'Draft', 'Refinement', 'Ready to Bounce', 'Bouncing',
|
|
26
|
+
'QA Passed', 'Architect Passed', 'Done', 'Escalated', 'Parking Lot'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function readState() {
|
|
30
|
+
if (!fs.existsSync(STATE_FILE)) {
|
|
31
|
+
console.error(`ERROR: ${STATE_FILE} not found. Run: vbounce sprint init S-XX D-XX`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error(`ERROR: state.json is not valid JSON — ${e.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeState(state) {
|
|
43
|
+
state.updated_at = new Date().toISOString();
|
|
44
|
+
const { valid, errors } = validateState(state);
|
|
45
|
+
if (!valid) {
|
|
46
|
+
console.error('ERROR: Would write invalid state:');
|
|
47
|
+
errors.forEach(e => console.error(` - ${e}`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const args = process.argv.slice(2);
|
|
54
|
+
|
|
55
|
+
if (args.length === 0) {
|
|
56
|
+
console.error('Usage:');
|
|
57
|
+
console.error(' update_state.mjs STORY-ID "New State"');
|
|
58
|
+
console.error(' update_state.mjs STORY-ID --qa-bounce');
|
|
59
|
+
console.error(' update_state.mjs STORY-ID --arch-bounce');
|
|
60
|
+
console.error(' update_state.mjs --set-phase "Phase N"');
|
|
61
|
+
console.error(' update_state.mjs --set-action "description"');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const state = readState();
|
|
66
|
+
|
|
67
|
+
// Global flags
|
|
68
|
+
if (args[0] === '--set-phase') {
|
|
69
|
+
state.phase = args[1];
|
|
70
|
+
writeState(state);
|
|
71
|
+
console.log(`✓ Phase set to: ${args[1]}`);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (args[0] === '--set-action') {
|
|
76
|
+
state.last_action = args[1];
|
|
77
|
+
writeState(state);
|
|
78
|
+
console.log(`✓ Last action set to: ${args[1]}`);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (args[0] === '--show') {
|
|
83
|
+
console.log(JSON.stringify(state, null, 2));
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Story-specific updates
|
|
88
|
+
const storyId = args[0];
|
|
89
|
+
if (!state.stories) {
|
|
90
|
+
console.error('ERROR: state.json has no stories field');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
if (!state.stories[storyId]) {
|
|
94
|
+
console.error(`ERROR: Story "${storyId}" not found in state.json`);
|
|
95
|
+
console.error(`Known stories: ${Object.keys(state.stories).join(', ')}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const flag = args[1];
|
|
100
|
+
|
|
101
|
+
if (flag === '--qa-bounce') {
|
|
102
|
+
state.stories[storyId].qa_bounces = (state.stories[storyId].qa_bounces || 0) + 1;
|
|
103
|
+
state.last_action = `QA bounce on ${storyId} (total: ${state.stories[storyId].qa_bounces})`;
|
|
104
|
+
writeState(state);
|
|
105
|
+
console.log(`✓ ${storyId} QA bounces: ${state.stories[storyId].qa_bounces}`);
|
|
106
|
+
|
|
107
|
+
} else if (flag === '--arch-bounce') {
|
|
108
|
+
state.stories[storyId].arch_bounces = (state.stories[storyId].arch_bounces || 0) + 1;
|
|
109
|
+
state.last_action = `Architect bounce on ${storyId} (total: ${state.stories[storyId].arch_bounces})`;
|
|
110
|
+
writeState(state);
|
|
111
|
+
console.log(`✓ ${storyId} Arch bounces: ${state.stories[storyId].arch_bounces}`);
|
|
112
|
+
|
|
113
|
+
} else if (flag) {
|
|
114
|
+
// New state
|
|
115
|
+
if (!VALID_STATES.includes(flag)) {
|
|
116
|
+
console.error(`ERROR: Invalid state "${flag}"`);
|
|
117
|
+
console.error(`Valid states: ${VALID_STATES.join(', ')}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
const prev = state.stories[storyId].state;
|
|
121
|
+
state.stories[storyId].state = flag;
|
|
122
|
+
if (flag === 'Done') {
|
|
123
|
+
state.stories[storyId].worktree = null;
|
|
124
|
+
}
|
|
125
|
+
state.last_action = `${storyId}: ${prev} → ${flag}`;
|
|
126
|
+
writeState(state);
|
|
127
|
+
console.log(`✓ ${storyId}: ${prev} → ${flag}`);
|
|
128
|
+
|
|
129
|
+
} else {
|
|
130
|
+
console.error('ERROR: Specify a state or flag (--qa-bounce, --arch-bounce)');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* validate_bounce_readiness.mjs
|
|
5
|
+
* Pre-bounce gate check — verifies a story is ready to bounce.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/validate_bounce_readiness.mjs STORY-005-02
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { execSync } from 'child_process';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
18
|
+
|
|
19
|
+
const storyId = process.argv[2];
|
|
20
|
+
if (!storyId) {
|
|
21
|
+
console.error('Usage: validate_bounce_readiness.mjs STORY-ID');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const errors = [];
|
|
26
|
+
const warnings = [];
|
|
27
|
+
|
|
28
|
+
// 1. Check state.json
|
|
29
|
+
const stateFile = path.join(ROOT, '.bounce', 'state.json');
|
|
30
|
+
if (!fs.existsSync(stateFile)) {
|
|
31
|
+
errors.push('.bounce/state.json not found — run: vbounce sprint init S-XX D-XX');
|
|
32
|
+
} else {
|
|
33
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
34
|
+
if (!state.stories[storyId]) {
|
|
35
|
+
errors.push(`Story "${storyId}" not found in state.json`);
|
|
36
|
+
} else {
|
|
37
|
+
const story = state.stories[storyId];
|
|
38
|
+
if (story.state !== 'Ready to Bounce') {
|
|
39
|
+
errors.push(`Story state is "${story.state}" — must be "Ready to Bounce" before bouncing`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Find sprint plan
|
|
45
|
+
const sprintsDir = path.join(ROOT, 'product_plans', 'sprints');
|
|
46
|
+
let sprintPlanFound = false;
|
|
47
|
+
if (fs.existsSync(sprintsDir)) {
|
|
48
|
+
const sprintDirs = fs.readdirSync(sprintsDir);
|
|
49
|
+
for (const dir of sprintDirs) {
|
|
50
|
+
const planFile = path.join(sprintsDir, dir, `${dir}.md`);
|
|
51
|
+
if (fs.existsSync(planFile)) {
|
|
52
|
+
sprintPlanFound = true;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!sprintPlanFound) {
|
|
58
|
+
warnings.push('No active Sprint Plan found in product_plans/sprints/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. Find story spec
|
|
62
|
+
let storyFile = null;
|
|
63
|
+
function findFile(dir, id) {
|
|
64
|
+
if (!fs.existsSync(dir)) return null;
|
|
65
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
const found = findFile(path.join(dir, e.name), id);
|
|
69
|
+
if (found) return found;
|
|
70
|
+
} else if (e.name.includes(id)) {
|
|
71
|
+
return path.join(dir, e.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
storyFile = findFile(path.join(ROOT, 'product_plans'), storyId);
|
|
78
|
+
if (!storyFile) {
|
|
79
|
+
errors.push(`Story spec not found for "${storyId}" in product_plans/`);
|
|
80
|
+
} else {
|
|
81
|
+
const storyContent = fs.readFileSync(storyFile, 'utf8');
|
|
82
|
+
|
|
83
|
+
// Check for §1, §2, §3
|
|
84
|
+
const hasSpec = /##\s*(1\.|§1|The Spec)/i.test(storyContent);
|
|
85
|
+
const hasCriteria = /##\s*(2\.|§2|The Truth|Acceptance)/i.test(storyContent);
|
|
86
|
+
const hasGuide = /##\s*(3\.|§3|Implementation)/i.test(storyContent);
|
|
87
|
+
|
|
88
|
+
if (!hasSpec) errors.push(`Story ${storyId}: §1 (spec) section not found`);
|
|
89
|
+
if (!hasCriteria) errors.push(`Story ${storyId}: §2 (acceptance criteria) section not found`);
|
|
90
|
+
if (!hasGuide) errors.push(`Story ${storyId}: §3 (implementation guide) section not found`);
|
|
91
|
+
|
|
92
|
+
// Check for minimum content in each section
|
|
93
|
+
const specMatch = storyContent.match(/##\s*(1\.|§1|The Spec)[^\n]*\n([\s\S]*?)(?=\n##|\n---|\Z)/i);
|
|
94
|
+
if (specMatch && specMatch[2].trim().length < 30) {
|
|
95
|
+
warnings.push(`Story ${storyId}: §1 spec section appears very short — verify it's complete`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4. Check worktree
|
|
100
|
+
const worktreeDir = path.join(ROOT, '.worktrees', storyId);
|
|
101
|
+
if (!fs.existsSync(worktreeDir)) {
|
|
102
|
+
warnings.push(`.worktrees/${storyId}/ not found — create with: git worktree add .worktrees/${storyId} -b story/${storyId} sprint/S-XX`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Print results
|
|
106
|
+
console.log(`Bounce readiness check: ${storyId}`);
|
|
107
|
+
console.log('');
|
|
108
|
+
|
|
109
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
110
|
+
console.log(`✓ ${storyId} is READY TO BOUNCE`);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (warnings.length > 0) {
|
|
115
|
+
warnings.forEach(w => console.warn(` ⚠ ${w}`));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (errors.length > 0) {
|
|
119
|
+
errors.forEach(e => console.error(` ✗ ${e}`));
|
|
120
|
+
console.error(`\nNOT READY: Fix ${errors.length} error(s) before bouncing ${storyId}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(` ✓ ${storyId} is ready (with warnings)`);
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
@@ -13,15 +13,21 @@ import path from 'path';
|
|
|
13
13
|
import yaml from 'js-yaml';
|
|
14
14
|
|
|
15
15
|
// Defined schemas for each report type
|
|
16
|
+
const ROOT_CAUSE_ENUM = [
|
|
17
|
+
'missing_tests', 'missing_validation', 'spec_ambiguity', 'adr_violation',
|
|
18
|
+
'gold_plating', 'logic_error', 'integration_gap', 'type_error',
|
|
19
|
+
'state_management', 'error_handling', 'coupling', 'duplication'
|
|
20
|
+
];
|
|
21
|
+
|
|
16
22
|
const SCHEMAS = {
|
|
17
23
|
dev: ['status', 'correction_tax', 'tokens_used', 'tests_written', 'files_modified', 'lessons_flagged'],
|
|
18
24
|
qa: {
|
|
19
25
|
base: ['status', 'bounce_count', 'tokens_used', 'bugs_found', 'gold_plating_detected'],
|
|
20
|
-
conditional: { 'FAIL': ['failed_scenarios'] }
|
|
26
|
+
conditional: { 'FAIL': ['failed_scenarios', 'root_cause'] }
|
|
21
27
|
},
|
|
22
28
|
arch: {
|
|
23
29
|
base: ['status', 'tokens_used'],
|
|
24
|
-
conditional: { 'PASS': ['safe_zone_score', 'ai_isms_detected', 'regression_risk'], 'FAIL': ['bounce_count', 'critical_failures'] }
|
|
30
|
+
conditional: { 'PASS': ['safe_zone_score', 'ai_isms_detected', 'regression_risk'], 'FAIL': ['bounce_count', 'critical_failures', 'root_cause'] }
|
|
25
31
|
},
|
|
26
32
|
devops: {
|
|
27
33
|
base: ['type', 'status', 'tokens_used'],
|
|
@@ -45,6 +51,29 @@ function validateDev(data) {
|
|
|
45
51
|
if (!Array.isArray(data.files_modified)) throw new Error(`DEV_SCHEMA_ERROR: 'files_modified' must be an array.`);
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
function validateBugsArray(bugs, prefix) {
|
|
55
|
+
if (!Array.isArray(bugs)) throw new Error(`${prefix}: 'bugs' must be an array.`);
|
|
56
|
+
bugs.forEach((bug, i) => {
|
|
57
|
+
const bugRequired = ['scenario', 'expected', 'actual', 'files', 'severity'];
|
|
58
|
+
const bugMissing = bugRequired.filter(k => !(k in bug));
|
|
59
|
+
if (bugMissing.length > 0) throw new Error(`${prefix}: bugs[${i}] missing keys: ${bugMissing.join(', ')}`);
|
|
60
|
+
if (!Array.isArray(bug.files)) throw new Error(`${prefix}: bugs[${i}].files must be an array.`);
|
|
61
|
+
const validSeverities = ['Critical', 'High', 'Medium', 'Low'];
|
|
62
|
+
if (!validSeverities.includes(bug.severity)) throw new Error(`${prefix}: bugs[${i}].severity must be one of: ${validSeverities.join(', ')}`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateFailuresArray(failures, prefix) {
|
|
67
|
+
if (!Array.isArray(failures)) throw new Error(`${prefix}: 'failures' must be an array.`);
|
|
68
|
+
const validDimensions = ['Architectural Consistency', 'Error Handling', 'Data Flow', 'Duplication', 'Test Quality', 'Coupling'];
|
|
69
|
+
failures.forEach((f, i) => {
|
|
70
|
+
const fRequired = ['dimension', 'severity', 'what_wrong', 'fix_required'];
|
|
71
|
+
const fMissing = fRequired.filter(k => !(k in f));
|
|
72
|
+
if (fMissing.length > 0) throw new Error(`${prefix}: failures[${i}] missing keys: ${fMissing.join(', ')}`);
|
|
73
|
+
if (!validDimensions.includes(f.dimension)) throw new Error(`${prefix}: failures[${i}].dimension must be one of: ${validDimensions.join(', ')}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
function validateQA(data) {
|
|
49
78
|
const missing = SCHEMAS.qa.base.filter(k => !(k in data));
|
|
50
79
|
if (missing.length > 0) throw new Error(`QA_SCHEMA_ERROR: Missing required keys: ${missing.join(', ')}`);
|
|
@@ -52,6 +81,10 @@ function validateQA(data) {
|
|
|
52
81
|
if (data.status === 'FAIL') {
|
|
53
82
|
const conditionalMissing = SCHEMAS.qa.conditional.FAIL.filter(k => !(k in data));
|
|
54
83
|
if (conditionalMissing.length > 0) throw new Error(`QA_SCHEMA_ERROR: 'FAIL' status requires keys: ${conditionalMissing.join(', ')}`);
|
|
84
|
+
if (data.root_cause && !ROOT_CAUSE_ENUM.includes(data.root_cause)) {
|
|
85
|
+
throw new Error(`QA_SCHEMA_ERROR: Invalid root_cause '${data.root_cause}'. Must be one of: ${ROOT_CAUSE_ENUM.join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
if ('bugs' in data) validateBugsArray(data.bugs, 'QA_SCHEMA_ERROR');
|
|
55
88
|
}
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -62,6 +95,10 @@ function validateArch(data) {
|
|
|
62
95
|
const s = data.status === 'PASS' ? 'PASS' : 'FAIL';
|
|
63
96
|
const conditionalMissing = SCHEMAS.arch.conditional[s].filter(k => !(k in data));
|
|
64
97
|
if (conditionalMissing.length > 0) throw new Error(`ARCH_SCHEMA_ERROR: '${s}' status requires keys: ${conditionalMissing.join(', ')}`);
|
|
98
|
+
if (s === 'FAIL' && data.root_cause && !ROOT_CAUSE_ENUM.includes(data.root_cause)) {
|
|
99
|
+
throw new Error(`ARCH_SCHEMA_ERROR: Invalid root_cause '${data.root_cause}'. Must be one of: ${ROOT_CAUSE_ENUM.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
if (s === 'FAIL' && 'failures' in data) validateFailuresArray(data.failures, 'ARCH_SCHEMA_ERROR');
|
|
65
102
|
}
|
|
66
103
|
|
|
67
104
|
function validateDevops(data) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* validate_sprint_plan.mjs
|
|
5
|
+
* Validates a Sprint Plan markdown file structure and cross-checks with state.json.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/validate_sprint_plan.mjs product_plans/sprints/sprint-05/sprint-05.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import yaml from 'js-yaml';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
18
|
+
|
|
19
|
+
const filePath = process.argv[2];
|
|
20
|
+
if (!filePath) {
|
|
21
|
+
console.error('Usage: validate_sprint_plan.mjs <path-to-sprint-plan.md>');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const absPath = path.resolve(filePath);
|
|
26
|
+
if (!fs.existsSync(absPath)) {
|
|
27
|
+
console.error(`ERROR: File not found: ${absPath}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
32
|
+
const errors = [];
|
|
33
|
+
const warnings = [];
|
|
34
|
+
|
|
35
|
+
// 1. Extract YAML frontmatter
|
|
36
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
37
|
+
if (!fmMatch) {
|
|
38
|
+
errors.push('Missing YAML frontmatter (--- delimiters)');
|
|
39
|
+
} else {
|
|
40
|
+
let fm;
|
|
41
|
+
try {
|
|
42
|
+
fm = yaml.load(fmMatch[1]);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
errors.push(`Invalid YAML frontmatter: ${e.message}`);
|
|
45
|
+
fm = {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const required = ['sprint_id', 'sprint_goal', 'dates', 'status', 'delivery'];
|
|
49
|
+
for (const f of required) {
|
|
50
|
+
if (!fm[f]) errors.push(`Frontmatter missing required field: "${f}"`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Cross-check with state.json
|
|
54
|
+
const stateFile = path.join(ROOT, '.bounce', 'state.json');
|
|
55
|
+
if (fs.existsSync(stateFile)) {
|
|
56
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
57
|
+
|
|
58
|
+
// Find story IDs in §1 table
|
|
59
|
+
const tableRowRegex = /\|\s*\d+\s*\|\s*\[?(STORY-[\w-]+)/g;
|
|
60
|
+
const planStoryIds = new Set();
|
|
61
|
+
let m;
|
|
62
|
+
while ((m = tableRowRegex.exec(content)) !== null) {
|
|
63
|
+
planStoryIds.add(m[1]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stateStoryIds = new Set(Object.keys(state.stories || {}));
|
|
67
|
+
|
|
68
|
+
// Check for stories in plan but not in state
|
|
69
|
+
for (const id of planStoryIds) {
|
|
70
|
+
if (!stateStoryIds.has(id)) {
|
|
71
|
+
warnings.push(`Story ${id} is in Sprint Plan §1 but NOT in state.json`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for stories in state but not in plan
|
|
76
|
+
for (const id of stateStoryIds) {
|
|
77
|
+
if (!planStoryIds.has(id)) {
|
|
78
|
+
warnings.push(`Story ${id} is in state.json but NOT in Sprint Plan §1`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Check §4 Execution Log if sprint is Completed
|
|
84
|
+
if (fm.status === 'Completed') {
|
|
85
|
+
if (!content.includes('<!-- EXECUTION_LOG_START -->') && !content.includes('## 4.') && !content.includes('## §4')) {
|
|
86
|
+
errors.push('Sprint is Completed but §4 Execution Log section is missing');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Check §1 table columns
|
|
92
|
+
if (!content.includes('| Priority |') && !content.includes('|Priority|')) {
|
|
93
|
+
errors.push('§1 Active Scope table missing or malformed (expected "Priority" column header)');
|
|
94
|
+
}
|
|
95
|
+
if (!content.includes('V-Bounce State')) {
|
|
96
|
+
errors.push('§1 Active Scope table missing "V-Bounce State" column');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Print results
|
|
100
|
+
console.log(`Validating: ${filePath}`);
|
|
101
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
102
|
+
console.log('✓ Sprint Plan is valid');
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (warnings.length > 0) {
|
|
107
|
+
console.warn('Warnings:');
|
|
108
|
+
warnings.forEach(w => console.warn(` ⚠ ${w}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (errors.length > 0) {
|
|
112
|
+
console.error('Errors:');
|
|
113
|
+
errors.forEach(e => console.error(` ✗ ${e}`));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
} else {
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|