@sandrinio/vbounce 1.5.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 +12 -10
- package/brains/CHANGELOG.md +99 -1
- package/brains/CLAUDE.md +29 -22
- package/brains/GEMINI.md +47 -9
- package/brains/SETUP.md +11 -5
- package/brains/claude-agents/architect.md +22 -6
- package/brains/claude-agents/developer.md +2 -2
- package/brains/claude-agents/devops.md +3 -0
- package/brains/claude-agents/qa.md +25 -9
- package/brains/copilot/copilot-instructions.md +49 -0
- package/brains/cursor-rules/vbounce-process.mdc +9 -7
- 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_gate_config.sh +151 -0
- package/scripts/init_sprint.mjs +121 -0
- package/scripts/pre_gate_common.sh +576 -0
- package/scripts/pre_gate_runner.sh +176 -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 +56 -21
- 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 +27 -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,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(', ')}`);
|
|
@@ -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
|
+
}
|