@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,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prep_qa_context.mjs
|
|
5
|
+
* Generates a QA context pack for a story.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/prep_qa_context.mjs STORY-005-02
|
|
9
|
+
*
|
|
10
|
+
* Output: .bounce/qa-context-STORY-005-02.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import yaml from 'js-yaml';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
20
|
+
|
|
21
|
+
const storyId = process.argv[2];
|
|
22
|
+
if (!storyId) {
|
|
23
|
+
console.error('Usage: prep_qa_context.mjs STORY-ID');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_CONTEXT_LINES = 300;
|
|
28
|
+
|
|
29
|
+
function findFilesMatching(dir, pattern) {
|
|
30
|
+
const results = [];
|
|
31
|
+
if (!fs.existsSync(dir)) return results;
|
|
32
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
const full = path.join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) results.push(...findFilesMatching(full, pattern));
|
|
36
|
+
else if (pattern.test(e.name)) results.push(full);
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 1. Find dev report (required)
|
|
42
|
+
const devReportPattern = new RegExp(`${storyId.replace(/[-]/g, '[-]')}.*-dev\\.md$`);
|
|
43
|
+
const searchDirs = [
|
|
44
|
+
path.join(ROOT, '.worktrees', storyId, '.bounce', 'reports'),
|
|
45
|
+
path.join(ROOT, '.bounce', 'reports'),
|
|
46
|
+
path.join(ROOT, '.bounce', 'archive'),
|
|
47
|
+
];
|
|
48
|
+
let devReport = null;
|
|
49
|
+
for (const dir of searchDirs) {
|
|
50
|
+
const matches = findFilesMatching(dir, devReportPattern);
|
|
51
|
+
if (matches.length > 0) { devReport = matches[0]; break; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!devReport) {
|
|
55
|
+
console.error(`ERROR: Dev report not found for ${storyId}. Searched in:`);
|
|
56
|
+
searchDirs.forEach(d => console.error(` - ${d}`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse dev report frontmatter
|
|
61
|
+
let devFm = {};
|
|
62
|
+
try {
|
|
63
|
+
const devContent = fs.readFileSync(devReport, 'utf8');
|
|
64
|
+
const fmMatch = devContent.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
65
|
+
if (fmMatch) devFm = yaml.load(fmMatch[1]) || {};
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(`ERROR: Dev report has invalid YAML frontmatter — ${e.message}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Find story spec (required)
|
|
72
|
+
const storySpecPattern = new RegExp(`${storyId.replace(/[-]/g, '[-]')}.*\\.md$`);
|
|
73
|
+
const storySpecMatches = findFilesMatching(path.join(ROOT, 'product_plans'), storySpecPattern);
|
|
74
|
+
if (storySpecMatches.length === 0) {
|
|
75
|
+
console.error(`ERROR: Story spec not found for ${storyId} in product_plans/`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const storySpecFile = storySpecMatches[0];
|
|
79
|
+
const storyContent = fs.readFileSync(storySpecFile, 'utf8');
|
|
80
|
+
|
|
81
|
+
// Extract §2 acceptance criteria
|
|
82
|
+
const criteriaMatch = storyContent.match(/##\s*(2\.|§2|The Truth|Acceptance)[^\n]*\n([\s\S]*?)(?=\n##|\n---|\Z)/i);
|
|
83
|
+
const criteriaSection = criteriaMatch ? criteriaMatch[2].trim().split('\n').slice(0, 30).join('\n') : '_Could not extract §2 — read story spec directly_';
|
|
84
|
+
|
|
85
|
+
// 3. Read LESSONS.md
|
|
86
|
+
const lessonsFile = path.join(ROOT, 'LESSONS.md');
|
|
87
|
+
let lessonsExcerpt = '_No LESSONS.md found_';
|
|
88
|
+
if (fs.existsSync(lessonsFile)) {
|
|
89
|
+
const lines = fs.readFileSync(lessonsFile, 'utf8').split('\n');
|
|
90
|
+
lessonsExcerpt = lines.slice(0, 30).join('\n');
|
|
91
|
+
if (lines.length > 30) lessonsExcerpt += `\n_(+${lines.length - 30} more lines)_`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. Format files modified list
|
|
95
|
+
const filesModified = Array.isArray(devFm.files_modified)
|
|
96
|
+
? devFm.files_modified.map(f => `- ${f}`).join('\n')
|
|
97
|
+
: '_Not specified in dev report_';
|
|
98
|
+
|
|
99
|
+
// 5. Assemble context pack
|
|
100
|
+
const lines = [
|
|
101
|
+
`# QA Context: ${storyId}`,
|
|
102
|
+
`> Generated: ${new Date().toISOString().split('T')[0]}`,
|
|
103
|
+
`> Dev report: ${path.relative(ROOT, devReport)}`,
|
|
104
|
+
`> Story spec: ${path.relative(ROOT, storySpecFile)}`,
|
|
105
|
+
'',
|
|
106
|
+
`## Dev Report Summary`,
|
|
107
|
+
`| Field | Value |`,
|
|
108
|
+
`|-------|-------|`,
|
|
109
|
+
`| Status | ${devFm.status || '—'} |`,
|
|
110
|
+
`| Correction Tax | ${devFm.correction_tax || '—'} |`,
|
|
111
|
+
`| Tests Written | ${devFm.tests_written ?? '—'} |`,
|
|
112
|
+
`| Lessons Flagged | ${devFm.lessons_flagged || 'none'} |`,
|
|
113
|
+
'',
|
|
114
|
+
`## Story Acceptance Criteria (§2)`,
|
|
115
|
+
criteriaSection,
|
|
116
|
+
'',
|
|
117
|
+
`## Files Modified`,
|
|
118
|
+
filesModified,
|
|
119
|
+
'',
|
|
120
|
+
`## Relevant Lessons`,
|
|
121
|
+
lessonsExcerpt,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const output = lines.join('\n');
|
|
125
|
+
const outputLines = output.split('\n');
|
|
126
|
+
let finalOutput = output;
|
|
127
|
+
if (outputLines.length > MAX_CONTEXT_LINES) {
|
|
128
|
+
finalOutput = outputLines.slice(0, MAX_CONTEXT_LINES).join('\n');
|
|
129
|
+
finalOutput += `\n\n> ⚠ Truncated at ${MAX_CONTEXT_LINES} lines. Read source files for complete content.`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const outputFile = path.join(ROOT, '.bounce', `qa-context-${storyId}.md`);
|
|
133
|
+
fs.writeFileSync(outputFile, finalOutput);
|
|
134
|
+
console.log(`✓ QA context pack written to .bounce/qa-context-${storyId}.md`);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prep_sprint_context.mjs
|
|
5
|
+
* Generates a sprint context pack — single file replacing 6+ separate reads.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/prep_sprint_context.mjs S-05
|
|
9
|
+
*
|
|
10
|
+
* Output: .bounce/sprint-context-S-05.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
|
|
20
|
+
const sprintId = process.argv[2];
|
|
21
|
+
if (!sprintId) {
|
|
22
|
+
console.error('Usage: prep_sprint_context.mjs S-XX');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAX_CONTEXT_LINES = 200;
|
|
27
|
+
|
|
28
|
+
// 1. Read state.json (required)
|
|
29
|
+
const stateFile = path.join(ROOT, '.bounce', 'state.json');
|
|
30
|
+
if (!fs.existsSync(stateFile)) {
|
|
31
|
+
console.error('ERROR: .bounce/state.json not found. Run: vbounce sprint init');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
35
|
+
|
|
36
|
+
// 2. Find sprint plan
|
|
37
|
+
const sprintNum = sprintId.replace('S-', '');
|
|
38
|
+
const sprintPlanPath = path.join(ROOT, 'product_plans', 'sprints', `sprint-${sprintNum}`, `sprint-${sprintNum}.md`);
|
|
39
|
+
if (!fs.existsSync(sprintPlanPath)) {
|
|
40
|
+
console.error(`ERROR: Sprint plan not found at ${sprintPlanPath}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const sprintPlan = fs.readFileSync(sprintPlanPath, 'utf8');
|
|
44
|
+
|
|
45
|
+
// Extract sprint goal from frontmatter
|
|
46
|
+
const goalMatch = sprintPlan.match(/sprint_goal:\s*"([^"]+)"/);
|
|
47
|
+
const sprintGoal = goalMatch ? goalMatch[1] : 'TBD';
|
|
48
|
+
|
|
49
|
+
// 3. Read LESSONS.md (first 50 lines)
|
|
50
|
+
const lessonsFile = path.join(ROOT, 'LESSONS.md');
|
|
51
|
+
let lessonsExcerpt = '_No LESSONS.md found_';
|
|
52
|
+
if (fs.existsSync(lessonsFile)) {
|
|
53
|
+
const lines = fs.readFileSync(lessonsFile, 'utf8').split('\n');
|
|
54
|
+
lessonsExcerpt = lines.slice(0, 50).join('\n');
|
|
55
|
+
if (lines.length > 50) lessonsExcerpt += `\n\n_(${lines.length - 50} more lines — read LESSONS.md for full content)_`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 4. Find RISK_REGISTRY
|
|
59
|
+
let riskExcerpt = '_No RISK_REGISTRY.md found_';
|
|
60
|
+
const riskPaths = [
|
|
61
|
+
path.join(ROOT, 'product_plans', 'strategy', 'RISK_REGISTRY.md'),
|
|
62
|
+
path.join(ROOT, 'RISK_REGISTRY.md'),
|
|
63
|
+
];
|
|
64
|
+
for (const rp of riskPaths) {
|
|
65
|
+
if (fs.existsSync(rp)) {
|
|
66
|
+
const lines = fs.readFileSync(rp, 'utf8').split('\n');
|
|
67
|
+
riskExcerpt = lines.slice(0, 20).join('\n');
|
|
68
|
+
if (lines.length > 20) riskExcerpt += `\n\n_(${lines.length - 20} more lines — read RISK_REGISTRY.md for full content)_`;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 5. Build story state table from state.json
|
|
74
|
+
const storyRows = Object.entries(state.stories || {})
|
|
75
|
+
.map(([id, s]) => `| ${id} | ${s.state} | ${s.qa_bounces} | ${s.arch_bounces} | ${s.worktree || '—'} |`)
|
|
76
|
+
.join('\n');
|
|
77
|
+
|
|
78
|
+
// 6. Assemble context pack
|
|
79
|
+
const lines = [
|
|
80
|
+
`# Sprint Context: ${sprintId}`,
|
|
81
|
+
`> Generated: ${new Date().toISOString().split('T')[0]} | Sprint: ${sprintId} | Delivery: ${state.delivery_id}`,
|
|
82
|
+
'',
|
|
83
|
+
`## Sprint Plan Summary`,
|
|
84
|
+
`- **Goal**: ${sprintGoal}`,
|
|
85
|
+
`- **Phase**: ${state.phase || 'N/A'}`,
|
|
86
|
+
`- **Last action**: ${state.last_action || 'N/A'}`,
|
|
87
|
+
`- **Stories**: ${Object.keys(state.stories || {}).length}`,
|
|
88
|
+
'',
|
|
89
|
+
`## Current State`,
|
|
90
|
+
`| Story | State | QA Bounces | Arch Bounces | Worktree |`,
|
|
91
|
+
`|-------|-------|------------|--------------|----------|`,
|
|
92
|
+
storyRows || '| (no stories) | — | — | — | — |',
|
|
93
|
+
'',
|
|
94
|
+
`## Relevant Lessons`,
|
|
95
|
+
lessonsExcerpt,
|
|
96
|
+
'',
|
|
97
|
+
`## Risk Summary`,
|
|
98
|
+
riskExcerpt,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const output = lines.join('\n');
|
|
102
|
+
const outputLines = output.split('\n');
|
|
103
|
+
|
|
104
|
+
let finalOutput = output;
|
|
105
|
+
let truncated = false;
|
|
106
|
+
if (outputLines.length > MAX_CONTEXT_LINES) {
|
|
107
|
+
finalOutput = outputLines.slice(0, MAX_CONTEXT_LINES).join('\n');
|
|
108
|
+
finalOutput += `\n\n> ⚠ Context pack truncated at ${MAX_CONTEXT_LINES} lines (was ${outputLines.length}). Read source files for complete content.`;
|
|
109
|
+
truncated = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 7. Write output
|
|
113
|
+
const outputFile = path.join(ROOT, '.bounce', `sprint-context-${sprintId}.md`);
|
|
114
|
+
fs.writeFileSync(outputFile, finalOutput);
|
|
115
|
+
|
|
116
|
+
console.log(`✓ Sprint context pack written to .bounce/sprint-context-${sprintId}.md`);
|
|
117
|
+
if (truncated) console.warn(` ⚠ Content was truncated (exceeded ${MAX_CONTEXT_LINES} lines)`);
|
|
118
|
+
console.log(` Stories: ${Object.keys(state.stories || {}).length} | Phase: ${state.phase || 'N/A'}`);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prep_sprint_summary.mjs
|
|
5
|
+
* Generates sprint metrics summary from archived agent reports.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/prep_sprint_summary.mjs S-05
|
|
9
|
+
*
|
|
10
|
+
* Output: .bounce/sprint-summary-S-05.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import yaml from 'js-yaml';
|
|
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: prep_sprint_summary.mjs S-XX');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const archiveDir = path.join(ROOT, '.bounce', 'archive', sprintId);
|
|
28
|
+
if (!fs.existsSync(archiveDir)) {
|
|
29
|
+
console.error(`ERROR: Archive directory not found: ${archiveDir}`);
|
|
30
|
+
console.error('No archived reports found for ' + sprintId);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findReports(dir) {
|
|
35
|
+
const results = [];
|
|
36
|
+
if (!fs.existsSync(dir)) return results;
|
|
37
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const full = path.join(dir, e.name);
|
|
40
|
+
if (e.isDirectory()) results.push(...findReports(full));
|
|
41
|
+
else if (e.name.endsWith('.md')) results.push(full);
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allReports = findReports(archiveDir);
|
|
47
|
+
if (allReports.length === 0) {
|
|
48
|
+
console.error(`ERROR: No reports found in ${archiveDir}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseFm(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
55
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
56
|
+
if (match) return yaml.load(match[1]) || {};
|
|
57
|
+
} catch {}
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Categorize reports
|
|
62
|
+
const devReports = allReports.filter(f => /-dev(-bounce\d+)?\.md$/.test(f));
|
|
63
|
+
const qaReports = allReports.filter(f => /-qa(-bounce\d+)?\.md$/.test(f));
|
|
64
|
+
const archReports = allReports.filter(f => /-arch(-bounce\d+)?\.md$/.test(f));
|
|
65
|
+
|
|
66
|
+
// Extract unique story IDs
|
|
67
|
+
const storyIds = new Set();
|
|
68
|
+
const storyPattern = /(STORY-[\w-]+)-(?:dev|qa|arch|devops)/;
|
|
69
|
+
for (const r of allReports) {
|
|
70
|
+
const m = path.basename(r).match(storyPattern);
|
|
71
|
+
if (m) storyIds.add(m[1]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Metrics
|
|
75
|
+
let totalTokens = 0;
|
|
76
|
+
let totalQaBounces = 0;
|
|
77
|
+
let totalArchBounces = 0;
|
|
78
|
+
let correctionTaxSum = 0;
|
|
79
|
+
let correctionTaxCount = 0;
|
|
80
|
+
let firstPassCount = 0;
|
|
81
|
+
let escalatedCount = 0;
|
|
82
|
+
const storyMetrics = {};
|
|
83
|
+
|
|
84
|
+
for (const id of storyIds) {
|
|
85
|
+
storyMetrics[id] = { qaBounces: 0, archBounces: 0, correctionTax: 0, done: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const r of devReports) {
|
|
89
|
+
const fm = parseFm(r);
|
|
90
|
+
if (fm.tokens_used) totalTokens += fm.tokens_used;
|
|
91
|
+
const tax = parseFloat(String(fm.correction_tax || '0').replace('%', ''));
|
|
92
|
+
if (!isNaN(tax)) { correctionTaxSum += tax; correctionTaxCount++; }
|
|
93
|
+
const m = path.basename(r).match(storyPattern);
|
|
94
|
+
if (m && storyMetrics[m[1]]) storyMetrics[m[1]].correctionTax = tax;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const r of qaReports) {
|
|
98
|
+
const fm = parseFm(r);
|
|
99
|
+
if (fm.tokens_used) totalTokens += fm.tokens_used;
|
|
100
|
+
if (fm.status === 'FAIL') {
|
|
101
|
+
const m = path.basename(r).match(storyPattern);
|
|
102
|
+
if (m && storyMetrics[m[1]]) storyMetrics[m[1]].qaBounces++;
|
|
103
|
+
totalQaBounces++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const r of archReports) {
|
|
108
|
+
const fm = parseFm(r);
|
|
109
|
+
if (fm.tokens_used) totalTokens += fm.tokens_used;
|
|
110
|
+
if (fm.status === 'FAIL') {
|
|
111
|
+
const m = path.basename(r).match(storyPattern);
|
|
112
|
+
if (m && storyMetrics[m[1]]) storyMetrics[m[1]].archBounces++;
|
|
113
|
+
totalArchBounces++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const [id, m] of Object.entries(storyMetrics)) {
|
|
118
|
+
if (m.qaBounces === 0) firstPassCount++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const totalStories = storyIds.size;
|
|
122
|
+
const avgCorrectionTax = correctionTaxCount > 0 ? (correctionTaxSum / correctionTaxCount).toFixed(1) : '0.0';
|
|
123
|
+
const firstPassRate = totalStories > 0 ? ((firstPassCount / totalStories) * 100).toFixed(0) : '0';
|
|
124
|
+
|
|
125
|
+
// Build story breakdown table
|
|
126
|
+
const storyBreakdownRows = [...storyIds].map(id => {
|
|
127
|
+
const m = storyMetrics[id];
|
|
128
|
+
return `| ${id} | ${m.qaBounces} | ${m.archBounces} | ${m.correctionTax}% |`;
|
|
129
|
+
}).join('\n');
|
|
130
|
+
|
|
131
|
+
const output = [
|
|
132
|
+
`# Sprint Summary: ${sprintId}`,
|
|
133
|
+
`> Generated: ${new Date().toISOString().split('T')[0]}`,
|
|
134
|
+
'',
|
|
135
|
+
`## Metrics`,
|
|
136
|
+
`| Metric | Value |`,
|
|
137
|
+
`|--------|-------|`,
|
|
138
|
+
`| Total Stories | ${totalStories} |`,
|
|
139
|
+
`| First-Pass Rate | ${firstPassRate}% |`,
|
|
140
|
+
`| Total QA Bounces | ${totalQaBounces} |`,
|
|
141
|
+
`| Total Arch Bounces | ${totalArchBounces} |`,
|
|
142
|
+
`| Avg Correction Tax | ${avgCorrectionTax}% |`,
|
|
143
|
+
`| Total Tokens Used | ${totalTokens.toLocaleString()} |`,
|
|
144
|
+
'',
|
|
145
|
+
`## Story Breakdown`,
|
|
146
|
+
`| Story | QA Bounces | Arch Bounces | Correction Tax |`,
|
|
147
|
+
`|-------|------------|--------------|----------------|`,
|
|
148
|
+
storyBreakdownRows || '| (no stories) | — | — | — |',
|
|
149
|
+
].join('\n');
|
|
150
|
+
|
|
151
|
+
const outputFile = path.join(ROOT, '.bounce', `sprint-summary-${sprintId}.md`);
|
|
152
|
+
fs.writeFileSync(outputFile, output);
|
|
153
|
+
console.log(`✓ Sprint summary written to .bounce/sprint-summary-${sprintId}.md`);
|
|
154
|
+
console.log(` Stories: ${totalStories} | First-pass: ${firstPassRate}% | Total tokens: ${totalTokens.toLocaleString()}`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sprint_trends.mjs
|
|
5
|
+
* Cross-sprint trend analysis from archived reports.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ./scripts/sprint_trends.mjs
|
|
9
|
+
*
|
|
10
|
+
* Output: .bounce/trends.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import yaml from 'js-yaml';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
20
|
+
|
|
21
|
+
const archiveBase = path.join(ROOT, '.bounce', 'archive');
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(archiveBase)) {
|
|
24
|
+
console.log('No sprint history found (.bounce/archive/ does not exist)');
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sprintDirs = fs.readdirSync(archiveBase)
|
|
29
|
+
.filter(d => /^S-\d{2}$/.test(d))
|
|
30
|
+
.sort();
|
|
31
|
+
|
|
32
|
+
if (sprintDirs.length === 0) {
|
|
33
|
+
console.log('No sprint history found (no S-XX directories in .bounce/archive/)');
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findReports(dir) {
|
|
38
|
+
const results = [];
|
|
39
|
+
if (!fs.existsSync(dir)) return results;
|
|
40
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
const full = path.join(dir, e.name);
|
|
43
|
+
if (e.isDirectory()) results.push(...findReports(full));
|
|
44
|
+
else if (e.name.endsWith('.md')) results.push(full);
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseFm(filePath) {
|
|
50
|
+
try {
|
|
51
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
52
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
53
|
+
if (match) return yaml.load(match[1]) || {};
|
|
54
|
+
} catch {}
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sprintStats = [];
|
|
59
|
+
const rootCauseCounts = {};
|
|
60
|
+
|
|
61
|
+
for (const sprintId of sprintDirs) {
|
|
62
|
+
const sprintDir = path.join(archiveBase, sprintId);
|
|
63
|
+
const reports = findReports(sprintDir);
|
|
64
|
+
|
|
65
|
+
const storyIds = new Set();
|
|
66
|
+
const pattern = /(STORY-[\w-]+)-(?:dev|qa|arch|devops)/;
|
|
67
|
+
for (const r of reports) {
|
|
68
|
+
const m = path.basename(r).match(pattern);
|
|
69
|
+
if (m) storyIds.add(m[1]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let qaFails = 0, archFails = 0, firstPassCount = 0;
|
|
73
|
+
let correctionTaxSum = 0, correctionTaxCount = 0, totalTokens = 0;
|
|
74
|
+
const storyQaBounces = {};
|
|
75
|
+
|
|
76
|
+
for (const r of reports) {
|
|
77
|
+
const fm = parseFm(r);
|
|
78
|
+
if (fm.tokens_used) totalTokens += fm.tokens_used;
|
|
79
|
+
|
|
80
|
+
const bn = path.basename(r);
|
|
81
|
+
if (/-qa(-bounce\d+)?\.md$/.test(bn) && fm.status === 'FAIL') {
|
|
82
|
+
qaFails++;
|
|
83
|
+
const m = bn.match(pattern);
|
|
84
|
+
if (m) storyQaBounces[m[1]] = (storyQaBounces[m[1]] || 0) + 1;
|
|
85
|
+
|
|
86
|
+
// Collect root causes
|
|
87
|
+
if (fm.root_cause) {
|
|
88
|
+
rootCauseCounts[fm.root_cause] = rootCauseCounts[fm.root_cause] || {};
|
|
89
|
+
rootCauseCounts[fm.root_cause][sprintId] = (rootCauseCounts[fm.root_cause][sprintId] || 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (/-arch(-bounce\d+)?\.md$/.test(bn) && fm.status === 'FAIL') {
|
|
93
|
+
archFails++;
|
|
94
|
+
if (fm.root_cause) {
|
|
95
|
+
rootCauseCounts[fm.root_cause] = rootCauseCounts[fm.root_cause] || {};
|
|
96
|
+
rootCauseCounts[fm.root_cause][sprintId] = (rootCauseCounts[fm.root_cause][sprintId] || 0) + 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (/-dev\.md$/.test(bn)) {
|
|
100
|
+
const tax = parseFloat(String(fm.correction_tax || '0').replace('%', ''));
|
|
101
|
+
if (!isNaN(tax)) { correctionTaxSum += tax; correctionTaxCount++; }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const [id] of Object.entries(storyQaBounces)) {
|
|
106
|
+
if (!storyQaBounces[id]) firstPassCount++;
|
|
107
|
+
}
|
|
108
|
+
// Stories with no QA failures = first pass
|
|
109
|
+
for (const id of storyIds) {
|
|
110
|
+
if (!storyQaBounces[id]) firstPassCount++;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const totalStories = storyIds.size;
|
|
114
|
+
const firstPassRate = totalStories > 0 ? Math.round((firstPassCount / totalStories) * 100) : 100;
|
|
115
|
+
const avgBounces = totalStories > 0 ? ((qaFails + archFails) / totalStories).toFixed(2) : '0.00';
|
|
116
|
+
const avgTax = correctionTaxCount > 0 ? (correctionTaxSum / correctionTaxCount).toFixed(1) : '0.0';
|
|
117
|
+
|
|
118
|
+
sprintStats.push({ sprintId, totalStories, firstPassRate, avgBounces, avgTax, totalTokens });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build process health table
|
|
122
|
+
const healthRows = sprintStats.map(s =>
|
|
123
|
+
`| ${s.sprintId} | ${s.totalStories} | ${s.firstPassRate}% | ${s.avgBounces} | ${s.avgTax}% | ${s.totalTokens.toLocaleString()} |`
|
|
124
|
+
).join('\n');
|
|
125
|
+
|
|
126
|
+
// Build root cause table if data available
|
|
127
|
+
let rootCauseSection = '';
|
|
128
|
+
const allCauses = Object.keys(rootCauseCounts);
|
|
129
|
+
if (allCauses.length > 0) {
|
|
130
|
+
const causeRows = allCauses.map(cause => {
|
|
131
|
+
const counts = sprintDirs.map(s => rootCauseCounts[cause][s] || 0);
|
|
132
|
+
return `| ${cause} | ${counts.join(' | ')} |`;
|
|
133
|
+
});
|
|
134
|
+
rootCauseSection = [
|
|
135
|
+
'',
|
|
136
|
+
`## Bounce Root Causes`,
|
|
137
|
+
`| Category | ${sprintDirs.join(' | ')} |`,
|
|
138
|
+
`|----------|${sprintDirs.map(() => '---').join('|')}|`,
|
|
139
|
+
...causeRows,
|
|
140
|
+
].join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const output = [
|
|
144
|
+
`# V-Bounce Trends`,
|
|
145
|
+
`> Generated: ${new Date().toISOString().split('T')[0]} | Sprints analyzed: ${sprintDirs.join(', ')}`,
|
|
146
|
+
'',
|
|
147
|
+
`## Process Health`,
|
|
148
|
+
`| Sprint | Stories | First-Pass Rate | Avg Bounces | Avg Correction Tax | Total Tokens |`,
|
|
149
|
+
`|--------|---------|----------------|-------------|-------------------|--------------|`,
|
|
150
|
+
healthRows || '| (no data) | — | — | — | — | — |',
|
|
151
|
+
rootCauseSection,
|
|
152
|
+
'',
|
|
153
|
+
`---`,
|
|
154
|
+
`Run \`vbounce suggest S-XX\` to generate improvement recommendations based on this data.`,
|
|
155
|
+
].join('\n');
|
|
156
|
+
|
|
157
|
+
const outputFile = path.join(ROOT, '.bounce', 'trends.md');
|
|
158
|
+
fs.writeFileSync(outputFile, output);
|
|
159
|
+
console.log(`✓ Trends written to .bounce/trends.md`);
|
|
160
|
+
console.log(` Sprints analyzed: ${sprintDirs.join(', ')}`);
|