@sandrinio/vbounce 1.9.0 → 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.
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * post_sprint_improve.mjs
5
+ * Post-sprint self-improvement analyzer.
6
+ *
7
+ * Parses sprint report §5 Framework Self-Assessment tables, cross-references
8
+ * LESSONS.md for automation candidates, and checks archived sprint reports
9
+ * for recurring patterns. Outputs a structured improvement manifest.
10
+ *
11
+ * Usage:
12
+ * ./scripts/post_sprint_improve.mjs S-05
13
+ *
14
+ * Output: .bounce/improvement-manifest.json
15
+ * (consumed by suggest_improvements.mjs and the /improve skill)
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const ROOT = path.resolve(__dirname, '..');
24
+
25
+ const sprintId = process.argv[2];
26
+ if (!sprintId || !/^S-\d{2}$/.test(sprintId)) {
27
+ console.error('Usage: post_sprint_improve.mjs S-XX');
28
+ process.exit(1);
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Impact Levels
33
+ // ---------------------------------------------------------------------------
34
+ // P0 Critical — Blocks agent work or causes incorrect output. Fix before next sprint.
35
+ // P1 High — Causes rework (bounces, wasted tokens, repeated manual steps). Fix this cycle.
36
+ // P2 Medium — Friction that slows agents but doesn't block. Fix within 2 sprints.
37
+ // P3 Low — Nice-to-have polish. Batch with other improvements.
38
+
39
+ const IMPACT = {
40
+ P0: { level: 'P0', label: 'Critical', description: 'Blocks agent work or causes incorrect output' },
41
+ P1: { level: 'P1', label: 'High', description: 'Causes rework — bounces, wasted tokens, repeated manual steps' },
42
+ P2: { level: 'P2', label: 'Medium', description: 'Friction that slows agents but does not block' },
43
+ P3: { level: 'P3', label: 'Low', description: 'Polish — nice-to-have, batch with other improvements' },
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // 1. Parse Sprint Report §5 Framework Self-Assessment
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Extract §5 findings from a sprint report file.
52
+ * Returns array of { area, finding, sourceAgent, severity, suggestedFix, sprintId }
53
+ */
54
+ function parseRetroFindings(reportPath, reportSprintId) {
55
+ if (!fs.existsSync(reportPath)) return [];
56
+
57
+ const content = fs.readFileSync(reportPath, 'utf8');
58
+ const findings = [];
59
+
60
+ // Match §5 section (or "## 5. Retrospective" / "## 5. Framework Self-Assessment")
61
+ const section5Match = content.match(/## 5\.\s+(Retrospective|Framework Self-Assessment)[\s\S]*?(?=\n## 6\.|$)/);
62
+ if (!section5Match) return findings;
63
+
64
+ const section5 = section5Match[0];
65
+
66
+ // Extract subsection areas
67
+ const areas = ['Templates', 'Agent Handoffs', 'RAG Pipeline', 'Skills', 'Process Flow', 'Tooling & Scripts'];
68
+
69
+ for (const area of areas) {
70
+ // Find the area's table within §5
71
+ const areaRegex = new RegExp(`####?\\s+${area.replace('&', '&')}[\\s\\S]*?(?=\\n####?\\s|\\n## |\\n---\\s*$|$)`);
72
+ const areaMatch = section5.match(areaRegex);
73
+ if (!areaMatch) continue;
74
+
75
+ const areaContent = areaMatch[0];
76
+
77
+ // Parse table rows: | Finding | Source Agent | Severity | Suggested Fix |
78
+ const rows = areaContent.split('\n').filter(line =>
79
+ line.startsWith('|') &&
80
+ !line.includes('Finding') &&
81
+ !line.includes('---') &&
82
+ line.split('|').length >= 5
83
+ );
84
+
85
+ for (const row of rows) {
86
+ const cells = row.split('|').map(c => c.trim()).filter(Boolean);
87
+ if (cells.length >= 4) {
88
+ // Skip template placeholder rows
89
+ if (cells[0].startsWith('{') || cells[0].startsWith('e.g.')) continue;
90
+
91
+ findings.push({
92
+ area,
93
+ finding: cells[0],
94
+ sourceAgent: cells[1],
95
+ severity: cells[2],
96
+ suggestedFix: cells[3],
97
+ sprintId: reportSprintId,
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ return findings;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // 2. Parse LESSONS.md for automation candidates
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Parse LESSONS.md and classify each lesson by automation potential.
112
+ * Returns array of { date, title, whatHappened, rule, age, automationType, impact }
113
+ */
114
+ function parseLessons(lessonsPath) {
115
+ if (!fs.existsSync(lessonsPath)) return [];
116
+
117
+ const content = fs.readFileSync(lessonsPath, 'utf8');
118
+ const lessons = [];
119
+ const today = new Date();
120
+
121
+ // Match lesson entries: ### [YYYY-MM-DD] Title
122
+ const entryRegex = /### \[(\d{4}-\d{2}-\d{2})\]\s+(.+?)(?=\n### \[|\n## |$)/gs;
123
+ let match;
124
+
125
+ while ((match = entryRegex.exec(content)) !== null) {
126
+ const date = match[1];
127
+ const title = match[2].trim();
128
+ const body = match[0];
129
+
130
+ const whatHappenedMatch = body.match(/\*\*What happened:\*\*\s*(.+)/);
131
+ const ruleMatch = body.match(/\*\*Rule:\*\*\s*(.+)/);
132
+
133
+ const lessonDate = new Date(date);
134
+ const ageInDays = Math.floor((today - lessonDate) / (1000 * 60 * 60 * 24));
135
+ const ageInSprints = Math.ceil(ageInDays / 14); // approximate 2-week sprints
136
+
137
+ const rule = ruleMatch ? ruleMatch[1].trim() : '';
138
+
139
+ // Classify automation potential based on rule keywords
140
+ const automationType = classifyLessonAutomation(rule);
141
+
142
+ lessons.push({
143
+ date,
144
+ title,
145
+ whatHappened: whatHappenedMatch ? whatHappenedMatch[1].trim() : '',
146
+ rule,
147
+ ageDays: ageInDays,
148
+ ageSprints: ageInSprints,
149
+ automationType,
150
+ });
151
+ }
152
+
153
+ return lessons;
154
+ }
155
+
156
+ /**
157
+ * Classify what type of automation a lesson rule could become.
158
+ */
159
+ function classifyLessonAutomation(rule) {
160
+ const lower = rule.toLowerCase();
161
+
162
+ // Gate check patterns: "Always check...", "Never use...", "Must have..."
163
+ if (/always (check|verify|ensure|validate|confirm|test|run)/i.test(lower)) return 'gate_check';
164
+ if (/never (use|import|add|create|modify|delete|skip)/i.test(lower)) return 'gate_check';
165
+ if (/must (have|include|contain|use|be)/i.test(lower)) return 'gate_check';
166
+ if (/do not|don't|avoid/i.test(lower)) return 'gate_check';
167
+
168
+ // Script patterns: "Run X before Y", "Use X instead of Y"
169
+ if (/run .+ before/i.test(lower)) return 'script';
170
+ if (/use .+ instead of/i.test(lower)) return 'script';
171
+
172
+ // Template patterns: "Include X in...", "Add X to..."
173
+ if (/include .+ in/i.test(lower)) return 'template_field';
174
+ if (/add .+ to (the )?(story|epic|sprint|report|template)/i.test(lower)) return 'template_field';
175
+
176
+ // Agent config patterns: general rules about behavior
177
+ if (/always|never|before|after/i.test(lower)) return 'agent_config';
178
+
179
+ return 'agent_config'; // default: graduate to agent brain
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // 3. Cross-reference archived sprint reports for recurring patterns
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Find findings that recur across multiple sprint reports.
188
+ * Returns map of finding → { count, sprints, latestSeverity }
189
+ */
190
+ function findRecurringPatterns(archiveDir, currentFindings) {
191
+ const allFindings = [...currentFindings];
192
+
193
+ // Read archived sprint reports
194
+ if (fs.existsSync(archiveDir)) {
195
+ const sprintDirs = fs.readdirSync(archiveDir).filter(d => /^S-\d{2}$/.test(d));
196
+ for (const dir of sprintDirs) {
197
+ const reportPath = path.join(archiveDir, dir, `sprint-report-${dir}.md`);
198
+ const archived = parseRetroFindings(reportPath, dir);
199
+ allFindings.push(...archived);
200
+ }
201
+ }
202
+
203
+ // Group by normalized finding text (lowercase, trimmed)
204
+ const patterns = {};
205
+ for (const f of allFindings) {
206
+ // Normalize: lowercase, remove quotes, collapse whitespace
207
+ const key = f.finding.toLowerCase().replace(/["']/g, '').replace(/\s+/g, ' ').trim();
208
+ if (!patterns[key]) {
209
+ patterns[key] = {
210
+ finding: f.finding,
211
+ area: f.area,
212
+ count: 0,
213
+ sprints: [],
214
+ severities: [],
215
+ suggestedFixes: [],
216
+ };
217
+ }
218
+ patterns[key].count++;
219
+ if (!patterns[key].sprints.includes(f.sprintId)) {
220
+ patterns[key].sprints.push(f.sprintId);
221
+ }
222
+ patterns[key].severities.push(f.severity);
223
+ if (f.suggestedFix && !patterns[key].suggestedFixes.includes(f.suggestedFix)) {
224
+ patterns[key].suggestedFixes.push(f.suggestedFix);
225
+ }
226
+ }
227
+
228
+ return patterns;
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // 4. Check previous improvement effectiveness
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Read improvement-log.md and check if applied improvements resolved their findings.
237
+ */
238
+ function checkImprovementEffectiveness(logPath, currentFindings) {
239
+ if (!fs.existsSync(logPath)) return [];
240
+
241
+ const content = fs.readFileSync(logPath, 'utf8');
242
+ const unresolved = [];
243
+
244
+ // Extract applied items
245
+ const appliedMatch = content.match(/## Applied\n[\s\S]*?(?=\n## |$)/);
246
+ if (!appliedMatch) return [];
247
+
248
+ const rows = appliedMatch[0].split('\n')
249
+ .filter(l => l.startsWith('|') && !l.startsWith('| Sprint') && !l.includes('---'));
250
+
251
+ for (const row of rows) {
252
+ const cells = row.split('|').map(c => c.trim()).filter(Boolean);
253
+ if (cells.length >= 3) {
254
+ const appliedTitle = cells[1];
255
+ // Check if any current finding matches the applied improvement
256
+ const stillPresent = currentFindings.some(f =>
257
+ f.finding.toLowerCase().includes(appliedTitle.toLowerCase()) ||
258
+ appliedTitle.toLowerCase().includes(f.finding.toLowerCase().substring(0, 30))
259
+ );
260
+ if (stillPresent) {
261
+ unresolved.push({
262
+ title: appliedTitle,
263
+ appliedInSprint: cells[0],
264
+ status: 'UNRESOLVED — finding persists after improvement was applied',
265
+ });
266
+ }
267
+ }
268
+ }
269
+
270
+ return unresolved;
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // 5. Generate improvement proposals
275
+ // ---------------------------------------------------------------------------
276
+
277
+ function generateProposals(currentFindings, lessons, patterns, unresolvedImprovements) {
278
+ const proposals = [];
279
+ let id = 1;
280
+
281
+ // --- From §5 findings ---
282
+ for (const finding of currentFindings) {
283
+ const patternKey = finding.finding.toLowerCase().replace(/["']/g, '').replace(/\s+/g, ' ').trim();
284
+ const pattern = patterns[patternKey];
285
+ const isRecurring = pattern && pattern.sprints.length > 1;
286
+
287
+ // Determine impact
288
+ let impact;
289
+ if (finding.severity === 'Blocker' && isRecurring) {
290
+ impact = IMPACT.P0;
291
+ } else if (finding.severity === 'Blocker') {
292
+ impact = IMPACT.P1;
293
+ } else if (isRecurring) {
294
+ impact = IMPACT.P1;
295
+ } else {
296
+ impact = IMPACT.P2;
297
+ }
298
+
299
+ proposals.push({
300
+ id: id++,
301
+ source: 'retro',
302
+ type: mapAreaToType(finding.area),
303
+ title: finding.finding,
304
+ area: finding.area,
305
+ sourceAgent: finding.sourceAgent,
306
+ severity: finding.severity,
307
+ suggestedFix: finding.suggestedFix,
308
+ impact,
309
+ recurring: isRecurring,
310
+ recurrenceCount: pattern ? pattern.sprints.length : 1,
311
+ recurrenceSprints: pattern ? pattern.sprints : [finding.sprintId],
312
+ });
313
+ }
314
+
315
+ // --- From lessons: automation candidates ---
316
+ for (const lesson of lessons) {
317
+ // Only propose automation for lessons 3+ sprints old (graduation candidates)
318
+ // or lessons with clear mechanical rules regardless of age
319
+ const isGraduationCandidate = lesson.ageSprints >= 3;
320
+ const isMechanical = lesson.automationType === 'gate_check' || lesson.automationType === 'script';
321
+
322
+ if (!isGraduationCandidate && !isMechanical) continue;
323
+
324
+ let impact;
325
+ if (isMechanical) {
326
+ // Mechanical checks save tokens every sprint
327
+ impact = IMPACT.P1;
328
+ } else if (isGraduationCandidate) {
329
+ impact = IMPACT.P2;
330
+ } else {
331
+ impact = IMPACT.P3;
332
+ }
333
+
334
+ proposals.push({
335
+ id: id++,
336
+ source: 'lesson',
337
+ type: lesson.automationType,
338
+ title: `Automate lesson: "${lesson.title}"`,
339
+ rule: lesson.rule,
340
+ whatHappened: lesson.whatHappened,
341
+ lessonDate: lesson.date,
342
+ ageSprints: lesson.ageSprints,
343
+ impact,
344
+ automationType: lesson.automationType,
345
+ automationDetail: generateAutomationDetail(lesson),
346
+ });
347
+ }
348
+
349
+ // --- From unresolved improvements ---
350
+ for (const unresolved of unresolvedImprovements) {
351
+ proposals.push({
352
+ id: id++,
353
+ source: 'effectiveness_check',
354
+ type: 're-examine',
355
+ title: `Unresolved: "${unresolved.title}"`,
356
+ detail: unresolved.status,
357
+ appliedInSprint: unresolved.appliedInSprint,
358
+ impact: IMPACT.P1, // Previous fix didn't work — escalate priority
359
+ });
360
+ }
361
+
362
+ // Sort by impact level (P0 first)
363
+ proposals.sort((a, b) => a.impact.level.localeCompare(b.impact.level));
364
+
365
+ return proposals;
366
+ }
367
+
368
+ function mapAreaToType(area) {
369
+ const map = {
370
+ 'Templates': 'template_patch',
371
+ 'Agent Handoffs': 'report_field',
372
+ 'RAG Pipeline': 'tooling',
373
+ 'Skills': 'skill_update',
374
+ 'Process Flow': 'process_change',
375
+ 'Tooling & Scripts': 'script',
376
+ };
377
+ return map[area] || 'other';
378
+ }
379
+
380
+ function generateAutomationDetail(lesson) {
381
+ switch (lesson.automationType) {
382
+ case 'gate_check':
383
+ return {
384
+ action: 'Add to gate-checks.json or pre_gate_runner.sh',
385
+ rationale: `Rule "${lesson.rule}" can be enforced mechanically via grep/lint pattern`,
386
+ effort: 'Low',
387
+ };
388
+ case 'script':
389
+ return {
390
+ action: 'Create or extend a validation script',
391
+ rationale: `Rule describes a procedural check that should run automatically`,
392
+ effort: 'Low-Medium',
393
+ };
394
+ case 'template_field':
395
+ return {
396
+ action: 'Add field or section to relevant template',
397
+ rationale: `Rule indicates missing information that should be captured at planning time`,
398
+ effort: 'Trivial',
399
+ };
400
+ case 'agent_config':
401
+ return {
402
+ action: 'Graduate to agent brain config (brains/claude-agents/*.md)',
403
+ rationale: `Lesson has been active ${lesson.ageSprints}+ sprints — promote to permanent rule`,
404
+ effort: 'Low',
405
+ };
406
+ default:
407
+ return { action: 'Review manually', rationale: 'Could not auto-classify', effort: 'Unknown' };
408
+ }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Main
413
+ // ---------------------------------------------------------------------------
414
+
415
+ const today = new Date().toISOString().split('T')[0];
416
+ const archiveDir = path.join(ROOT, '.bounce', 'archive');
417
+ const lessonsPath = path.join(ROOT, 'LESSONS.md');
418
+ const improvementLogPath = path.join(ROOT, '.bounce', 'improvement-log.md');
419
+
420
+ // Current sprint report
421
+ const reportPath = path.join(ROOT, '.bounce', `sprint-report-${sprintId}.md`);
422
+ const reportArchivePath = path.join(archiveDir, sprintId, `sprint-report-${sprintId}.md`);
423
+ const actualReportPath = fs.existsSync(reportPath) ? reportPath : reportArchivePath;
424
+
425
+ // 1. Parse current sprint retro
426
+ const currentFindings = parseRetroFindings(actualReportPath, sprintId);
427
+ console.log(` Retro findings from ${sprintId}: ${currentFindings.length}`);
428
+
429
+ // 2. Parse lessons
430
+ const lessons = parseLessons(lessonsPath);
431
+ console.log(` Lessons in LESSONS.md: ${lessons.length}`);
432
+
433
+ // 3. Cross-reference archived reports
434
+ const patterns = findRecurringPatterns(archiveDir, currentFindings);
435
+ const recurringCount = Object.values(patterns).filter(p => p.sprints.length > 1).length;
436
+ console.log(` Recurring patterns across sprints: ${recurringCount}`);
437
+
438
+ // 4. Check improvement effectiveness
439
+ const unresolved = checkImprovementEffectiveness(improvementLogPath, currentFindings);
440
+ if (unresolved.length > 0) {
441
+ console.log(` ⚠ Unresolved improvements from previous cycles: ${unresolved.length}`);
442
+ }
443
+
444
+ // 5. Generate proposals
445
+ const proposals = generateProposals(currentFindings, lessons, patterns, unresolved);
446
+
447
+ // 6. Write manifest
448
+ const manifest = {
449
+ sprintId,
450
+ generatedAt: today,
451
+ impactLevels: IMPACT,
452
+ summary: {
453
+ totalProposals: proposals.length,
454
+ byImpact: {
455
+ P0: proposals.filter(p => p.impact.level === 'P0').length,
456
+ P1: proposals.filter(p => p.impact.level === 'P1').length,
457
+ P2: proposals.filter(p => p.impact.level === 'P2').length,
458
+ P3: proposals.filter(p => p.impact.level === 'P3').length,
459
+ },
460
+ bySource: {
461
+ retro: proposals.filter(p => p.source === 'retro').length,
462
+ lesson: proposals.filter(p => p.source === 'lesson').length,
463
+ effectiveness_check: proposals.filter(p => p.source === 'effectiveness_check').length,
464
+ },
465
+ byType: {},
466
+ },
467
+ proposals,
468
+ };
469
+
470
+ // Count by type
471
+ for (const p of proposals) {
472
+ manifest.summary.byType[p.type] = (manifest.summary.byType[p.type] || 0) + 1;
473
+ }
474
+
475
+ const manifestPath = path.join(ROOT, '.bounce', 'improvement-manifest.json');
476
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
477
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
478
+
479
+ console.log('');
480
+ console.log(`✓ Improvement manifest written to .bounce/improvement-manifest.json`);
481
+ console.log(` ${proposals.length} proposal(s): ${manifest.summary.byImpact.P0} P0, ${manifest.summary.byImpact.P1} P1, ${manifest.summary.byImpact.P2} P2, ${manifest.summary.byImpact.P3} P3`);
482
+
483
+ if (proposals.length > 0) {
484
+ console.log('');
485
+ console.log('Next: run `vbounce suggest ' + sprintId + '` to generate human-readable improvement suggestions.');
486
+ }