@sandrinio/vbounce 1.7.0 → 1.8.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/bin/vbounce.mjs +16 -0
- package/brains/claude-agents/qa.md +1 -0
- package/package.json +1 -1
- package/scripts/prep_qa_context.mjs +19 -1
- package/scripts/prep_sprint_context.mjs +24 -1
- package/scripts/validate_bounce_readiness.mjs +27 -0
- package/scripts/vdoc_match.mjs +269 -0
- package/scripts/vdoc_staleness.mjs +199 -0
- package/skills/agent-team/SKILL.md +18 -11
- package/templates/sprint_report.md +6 -2
package/bin/vbounce.mjs
CHANGED
|
@@ -81,6 +81,8 @@ Usage:
|
|
|
81
81
|
vbounce prep qa <storyId> Generate QA context pack
|
|
82
82
|
vbounce prep arch <storyId> Generate Architect context pack
|
|
83
83
|
vbounce prep sprint <sprintId> Generate Sprint context pack
|
|
84
|
+
vbounce docs match --story <ID> Match story scope against vdoc manifest
|
|
85
|
+
vbounce docs check <sprintId> Detect stale vdocs and generate Scribe task
|
|
84
86
|
vbounce trends Cross-sprint trend analysis
|
|
85
87
|
vbounce suggest <sprintId> Generate improvement suggestions
|
|
86
88
|
vbounce doctor Validate all configs and state files
|
|
@@ -192,6 +194,20 @@ if (command === 'suggest') {
|
|
|
192
194
|
runScript('suggest_improvements.mjs', args.slice(1));
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
// -- docs --
|
|
198
|
+
if (command === 'docs') {
|
|
199
|
+
rl.close();
|
|
200
|
+
if (sub === 'match') {
|
|
201
|
+
runScript('vdoc_match.mjs', args.slice(2));
|
|
202
|
+
} else if (sub === 'check') {
|
|
203
|
+
runScript('vdoc_staleness.mjs', args.slice(2));
|
|
204
|
+
} else {
|
|
205
|
+
console.error(`Unknown docs subcommand: ${sub}`);
|
|
206
|
+
console.error('Usage: vbounce docs match --story <ID> | vbounce docs check <sprintId>');
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
// -- doctor --
|
|
196
212
|
if (command === 'doctor') {
|
|
197
213
|
rl.close();
|
|
@@ -15,6 +15,7 @@ Validate that the Developer's implementation meets the Story's acceptance criter
|
|
|
15
15
|
1. **Read LESSONS.md**: Scan for failure patterns relevant to this story. Treat matching entries as known risk areas to probe first.
|
|
16
16
|
2. **Read the Developer Implementation Report** (`.bounce/reports/STORY-{ID}-{StoryName}-dev.md`) to understand what was built.
|
|
17
17
|
3. **Read Story §2 The Truth** — these are your pass/fail criteria. If the Gherkin scenarios don't pass, the bounce failed.
|
|
18
|
+
4. **Check vdoc context**: If the QA context pack includes a `## vdoc Context` section, read the referenced product docs. Cross-reference the Developer's changes against documented behavior — if the implementation contradicts what a vdoc describes, flag it as a behavioral regression even if the Gherkin scenarios pass. Check the Blast Radius warnings for features that may be indirectly affected.
|
|
18
19
|
|
|
19
20
|
## Pre-Computed Scan Results
|
|
20
21
|
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
16
17
|
import yaml from 'js-yaml';
|
|
17
18
|
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -96,7 +97,23 @@ const filesModified = Array.isArray(devFm.files_modified)
|
|
|
96
97
|
? devFm.files_modified.map(f => `- ${f}`).join('\n')
|
|
97
98
|
: '_Not specified in dev report_';
|
|
98
99
|
|
|
99
|
-
// 5.
|
|
100
|
+
// 5. vdoc context (optional — graceful skip if no manifest)
|
|
101
|
+
let vdocSection = '';
|
|
102
|
+
const vdocMatchScript = path.join(__dirname, 'vdoc_match.mjs');
|
|
103
|
+
if (fs.existsSync(vdocMatchScript) && fs.existsSync(path.join(ROOT, 'vdocs', '_manifest.json'))) {
|
|
104
|
+
try {
|
|
105
|
+
const modifiedFiles = Array.isArray(devFm.files_modified) ? devFm.files_modified.join(',') : '';
|
|
106
|
+
const vdocArgs = [`--story`, storyId];
|
|
107
|
+
if (modifiedFiles) vdocArgs.push('--files', modifiedFiles);
|
|
108
|
+
vdocArgs.push('--context');
|
|
109
|
+
const vdocOutput = execSync(`node "${vdocMatchScript}" ${vdocArgs.map(a => `"${a}"`).join(' ')}`, { cwd: ROOT, encoding: 'utf8', timeout: 10000 }).trim();
|
|
110
|
+
if (vdocOutput && !vdocOutput.includes('No vdoc matches')) {
|
|
111
|
+
vdocSection = vdocOutput;
|
|
112
|
+
}
|
|
113
|
+
} catch { /* vdoc matching failed — skip silently */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 6. Assemble context pack
|
|
100
117
|
const lines = [
|
|
101
118
|
`# QA Context: ${storyId}`,
|
|
102
119
|
`> Generated: ${new Date().toISOString().split('T')[0]}`,
|
|
@@ -117,6 +134,7 @@ const lines = [
|
|
|
117
134
|
`## Files Modified`,
|
|
118
135
|
filesModified,
|
|
119
136
|
'',
|
|
137
|
+
...(vdocSection ? [vdocSection, ''] : []),
|
|
120
138
|
`## Relevant Lessons`,
|
|
121
139
|
lessonsExcerpt,
|
|
122
140
|
];
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -75,7 +76,28 @@ const storyRows = Object.entries(state.stories || {})
|
|
|
75
76
|
.map(([id, s]) => `| ${id} | ${s.state} | ${s.qa_bounces} | ${s.arch_bounces} | ${s.worktree || '—'} |`)
|
|
76
77
|
.join('\n');
|
|
77
78
|
|
|
78
|
-
// 6.
|
|
79
|
+
// 6. vdoc summary (optional — graceful skip if no manifest)
|
|
80
|
+
let vdocSummary = '';
|
|
81
|
+
const manifestPath = path.join(ROOT, 'vdocs', '_manifest.json');
|
|
82
|
+
if (fs.existsSync(manifestPath)) {
|
|
83
|
+
try {
|
|
84
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
85
|
+
const docCount = (manifest.documentation || []).length;
|
|
86
|
+
const docList = (manifest.documentation || []).slice(0, 10)
|
|
87
|
+
.map(d => `| ${d.filepath} | ${d.title} | ${(d.tags || []).slice(0, 4).join(', ')} | ${(d.deps || []).join(', ') || '—'} |`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
vdocSummary = [
|
|
90
|
+
`## Product Documentation (vdoc)`,
|
|
91
|
+
`> ${docCount} feature doc(s) available in vdocs/`,
|
|
92
|
+
'',
|
|
93
|
+
`| Doc | Title | Tags | Dependencies |`,
|
|
94
|
+
`|-----|-------|------|-------------|`,
|
|
95
|
+
docList,
|
|
96
|
+
].join('\n');
|
|
97
|
+
} catch { /* skip on parse error */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 7. Assemble context pack
|
|
79
101
|
const lines = [
|
|
80
102
|
`# Sprint Context: ${sprintId}`,
|
|
81
103
|
`> Generated: ${new Date().toISOString().split('T')[0]} | Sprint: ${sprintId} | Delivery: ${state.delivery_id}`,
|
|
@@ -91,6 +113,7 @@ const lines = [
|
|
|
91
113
|
`|-------|-------|------------|--------------|----------|`,
|
|
92
114
|
storyRows || '| (no stories) | — | — | — | — |',
|
|
93
115
|
'',
|
|
116
|
+
...(vdocSummary ? [vdocSummary, ''] : []),
|
|
94
117
|
`## Relevant Lessons`,
|
|
95
118
|
lessonsExcerpt,
|
|
96
119
|
'',
|
|
@@ -102,6 +102,33 @@ if (!fs.existsSync(worktreeDir)) {
|
|
|
102
102
|
warnings.push(`.worktrees/${storyId}/ not found — create with: git worktree add .worktrees/${storyId} -b story/${storyId} sprint/S-XX`);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// 5. vdoc impact check (warning only — never blocks bounce)
|
|
106
|
+
const manifestPath = path.join(ROOT, 'vdocs', '_manifest.json');
|
|
107
|
+
if (fs.existsSync(manifestPath) && storyFile) {
|
|
108
|
+
try {
|
|
109
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
110
|
+
const docs = manifest.documentation || [];
|
|
111
|
+
const storyLower = fs.readFileSync(storyFile, 'utf8').toLowerCase();
|
|
112
|
+
|
|
113
|
+
// Extract file paths mentioned in the story
|
|
114
|
+
const storyFileRefs = storyLower.match(/(?:src|lib|app|pages|components|api|services|scripts)\/[^\s,)'"]+/g) || [];
|
|
115
|
+
|
|
116
|
+
for (const doc of docs) {
|
|
117
|
+
const docKeyFiles = (doc.keyFiles || []).map(f => f.toLowerCase());
|
|
118
|
+
const overlap = docKeyFiles.filter(kf =>
|
|
119
|
+
storyFileRefs.some(sf => sf.includes(kf) || kf.includes(sf))
|
|
120
|
+
);
|
|
121
|
+
if (overlap.length > 0) {
|
|
122
|
+
warnings.push(`vdoc impact: ${doc.filepath} — key files overlap with story scope (${overlap.slice(0, 3).join(', ')}). Doc may need updating post-sprint.`);
|
|
123
|
+
const deps = doc.deps || [];
|
|
124
|
+
if (deps.length > 0) {
|
|
125
|
+
warnings.push(` ↳ Blast radius: ${deps.join(', ')} may also be affected`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch { /* skip on manifest parse error */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
// Print results
|
|
106
133
|
console.log(`Bounce readiness check: ${storyId}`);
|
|
107
134
|
console.log('');
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* vdoc_match.mjs
|
|
5
|
+
* Reads vdocs/_manifest.json and matches story scope against doc tags,
|
|
6
|
+
* descriptions, and key files. Returns relevant doc paths and blast radius.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ./scripts/vdoc_match.mjs --story STORY-005-02
|
|
10
|
+
* ./scripts/vdoc_match.mjs --files "src/auth/index.ts,src/middleware/auth.ts"
|
|
11
|
+
* ./scripts/vdoc_match.mjs --keywords "authentication,jwt,login"
|
|
12
|
+
*
|
|
13
|
+
* Output: JSON to stdout, human-readable to stderr
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
22
|
+
|
|
23
|
+
// ── Config ────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const configPath = path.join(ROOT, 'vbounce.config.json');
|
|
26
|
+
const config = fs.existsSync(configPath)
|
|
27
|
+
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
28
|
+
: {};
|
|
29
|
+
|
|
30
|
+
const MAX_MATCHES = config.vdocMaxMatches || 3;
|
|
31
|
+
const VDOCS_DIR = path.join(ROOT, 'vdocs');
|
|
32
|
+
const MANIFEST_PATH = path.join(VDOCS_DIR, '_manifest.json');
|
|
33
|
+
const SLICES_DIR = path.join(VDOCS_DIR, '_slices');
|
|
34
|
+
|
|
35
|
+
// ── Parse args ────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
let storyId = null;
|
|
39
|
+
let inputFiles = [];
|
|
40
|
+
let inputKeywords = [];
|
|
41
|
+
let outputFormat = 'human'; // human | json | context
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < args.length; i++) {
|
|
44
|
+
switch (args[i]) {
|
|
45
|
+
case '--story': storyId = args[++i]; break;
|
|
46
|
+
case '--files': inputFiles = args[++i].split(',').map(f => f.trim()); break;
|
|
47
|
+
case '--keywords': inputKeywords = args[++i].split(',').map(k => k.trim().toLowerCase()); break;
|
|
48
|
+
case '--json': outputFormat = 'json'; break;
|
|
49
|
+
case '--context': outputFormat = 'context'; break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!storyId && inputFiles.length === 0 && inputKeywords.length === 0) {
|
|
54
|
+
console.error('Usage: vdoc_match.mjs --story STORY-ID | --files "f1,f2" | --keywords "k1,k2"');
|
|
55
|
+
console.error(' --json Output JSON to stdout');
|
|
56
|
+
console.error(' --context Output context-pack markdown to stdout');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Load manifest ─────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(MANIFEST_PATH)) {
|
|
63
|
+
if (outputFormat === 'json') {
|
|
64
|
+
console.log(JSON.stringify({ matches: [], blastRadius: [], reason: 'no_manifest' }));
|
|
65
|
+
} else {
|
|
66
|
+
console.error('No vdocs/_manifest.json found — skipping vdoc matching');
|
|
67
|
+
}
|
|
68
|
+
process.exit(0); // Graceful no-op
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
72
|
+
const docs = manifest.documentation || [];
|
|
73
|
+
|
|
74
|
+
// ── Extract search terms from story ───────────────────────────────
|
|
75
|
+
|
|
76
|
+
if (storyId) {
|
|
77
|
+
// Find story spec to extract keywords and file references
|
|
78
|
+
const storyPattern = new RegExp(storyId.replace(/[-]/g, '[-]'));
|
|
79
|
+
const storyFiles = findFiles(path.join(ROOT, 'product_plans'), storyPattern);
|
|
80
|
+
|
|
81
|
+
if (storyFiles.length > 0) {
|
|
82
|
+
const storyContent = fs.readFileSync(storyFiles[0], 'utf8').toLowerCase();
|
|
83
|
+
|
|
84
|
+
// Extract file paths mentioned in the story
|
|
85
|
+
const fileRefs = storyContent.match(/(?:src|lib|app|pages|components|api|services|scripts)\/[^\s,)'"]+/g) || [];
|
|
86
|
+
inputFiles.push(...fileRefs);
|
|
87
|
+
|
|
88
|
+
// Extract tags from story frontmatter
|
|
89
|
+
const tagMatch = storyContent.match(/tags:\s*\[([^\]]+)\]/);
|
|
90
|
+
if (tagMatch) {
|
|
91
|
+
inputKeywords.push(...tagMatch[1].split(',').map(t => t.trim().replace(/['"]/g, '')));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Use story name parts as keywords
|
|
95
|
+
const nameParts = storyId.split('-').slice(3).join('-').replace(/_/g, ' ').split(/\s+/);
|
|
96
|
+
inputKeywords.push(...nameParts.filter(p => p.length > 2));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Score each doc ────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const scored = docs.map(doc => {
|
|
103
|
+
let score = 0;
|
|
104
|
+
const reasons = [];
|
|
105
|
+
|
|
106
|
+
// 1. File path overlap (strongest signal)
|
|
107
|
+
const docKeyFiles = extractKeyFiles(doc);
|
|
108
|
+
const fileOverlap = inputFiles.filter(f => docKeyFiles.some(kf => kf.includes(f) || f.includes(kf)));
|
|
109
|
+
if (fileOverlap.length > 0) {
|
|
110
|
+
score += fileOverlap.length * 10;
|
|
111
|
+
reasons.push(`file overlap: ${fileOverlap.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Tag match
|
|
115
|
+
const docTags = (doc.tags || []).map(t => t.toLowerCase());
|
|
116
|
+
const tagOverlap = inputKeywords.filter(k => docTags.includes(k));
|
|
117
|
+
if (tagOverlap.length > 0) {
|
|
118
|
+
score += tagOverlap.length * 5;
|
|
119
|
+
reasons.push(`tag match: ${tagOverlap.join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. Description keyword match
|
|
123
|
+
const desc = (doc.description || '').toLowerCase();
|
|
124
|
+
const descMatches = inputKeywords.filter(k => desc.includes(k));
|
|
125
|
+
if (descMatches.length > 0) {
|
|
126
|
+
score += descMatches.length * 3;
|
|
127
|
+
reasons.push(`description match: ${descMatches.join(', ')}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Title keyword match
|
|
131
|
+
const title = (doc.title || '').toLowerCase();
|
|
132
|
+
const titleMatches = inputKeywords.filter(k => title.includes(k));
|
|
133
|
+
if (titleMatches.length > 0) {
|
|
134
|
+
score += titleMatches.length * 2;
|
|
135
|
+
reasons.push(`title match: ${titleMatches.join(', ')}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ...doc, score, reasons };
|
|
139
|
+
}).filter(d => d.score > 0).sort((a, b) => b.score - a.score);
|
|
140
|
+
|
|
141
|
+
// ── Top matches ───────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
const topMatches = scored.slice(0, MAX_MATCHES);
|
|
144
|
+
|
|
145
|
+
// ── Blast radius (deps of matched docs) ──────────────────────────
|
|
146
|
+
|
|
147
|
+
const matchedFeatures = new Set(topMatches.map(d => d.title));
|
|
148
|
+
const blastRadius = [];
|
|
149
|
+
|
|
150
|
+
for (const match of topMatches) {
|
|
151
|
+
const deps = match.deps || [];
|
|
152
|
+
for (const dep of deps) {
|
|
153
|
+
if (!matchedFeatures.has(dep)) {
|
|
154
|
+
const depDoc = docs.find(d => d.title === dep || d.filepath.toLowerCase().includes(dep.toLowerCase().replace(/\s+/g, '_')));
|
|
155
|
+
if (depDoc) {
|
|
156
|
+
blastRadius.push({ feature: dep, doc: depDoc.filepath, triggeredBy: match.title });
|
|
157
|
+
} else {
|
|
158
|
+
blastRadius.push({ feature: dep, doc: null, triggeredBy: match.title });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Output ────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
const result = {
|
|
167
|
+
matches: topMatches.map(d => ({
|
|
168
|
+
filepath: d.filepath,
|
|
169
|
+
title: d.title,
|
|
170
|
+
score: d.score,
|
|
171
|
+
reasons: d.reasons,
|
|
172
|
+
deps: d.deps || [],
|
|
173
|
+
})),
|
|
174
|
+
blastRadius,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (outputFormat === 'json') {
|
|
178
|
+
console.log(JSON.stringify(result, null, 2));
|
|
179
|
+
} else if (outputFormat === 'context') {
|
|
180
|
+
// Output context-pack markdown using slices where available
|
|
181
|
+
if (topMatches.length === 0) {
|
|
182
|
+
console.log('<!-- No vdoc matches found -->');
|
|
183
|
+
} else {
|
|
184
|
+
console.log('## vdoc Context');
|
|
185
|
+
console.log('');
|
|
186
|
+
for (const match of topMatches) {
|
|
187
|
+
const sliceName = match.filepath.replace(/_DOC\.md$/, '_SLICE.md').replace(/\.md$/, '_SLICE.md');
|
|
188
|
+
const slicePath = path.join(SLICES_DIR, sliceName);
|
|
189
|
+
const fullPath = path.join(VDOCS_DIR, match.filepath);
|
|
190
|
+
|
|
191
|
+
if (fs.existsSync(slicePath)) {
|
|
192
|
+
console.log(fs.readFileSync(slicePath, 'utf8'));
|
|
193
|
+
} else if (fs.existsSync(fullPath)) {
|
|
194
|
+
// Fallback: extract Overview section from full doc
|
|
195
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
196
|
+
const overviewMatch = content.match(/## Overview\s*\n(?:<!--[^>]*-->\s*\n)?\s*([\s\S]*?)(?=\n---|\n##)/);
|
|
197
|
+
if (overviewMatch) {
|
|
198
|
+
console.log(`### ${match.title}`);
|
|
199
|
+
console.log(overviewMatch[1].trim().split('\n').slice(0, 5).join('\n'));
|
|
200
|
+
console.log('');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
console.log('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (blastRadius.length > 0) {
|
|
207
|
+
console.log('### Blast Radius Warning');
|
|
208
|
+
console.log('Changes to matched features may also affect:');
|
|
209
|
+
for (const br of blastRadius) {
|
|
210
|
+
console.log(`- **${br.feature}** (triggered by ${br.triggeredBy})${br.doc ? ` — see ${br.doc}` : ''}`);
|
|
211
|
+
}
|
|
212
|
+
console.log('');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// Human-readable to stderr
|
|
217
|
+
if (topMatches.length === 0) {
|
|
218
|
+
console.error('No vdoc matches found for the given scope.');
|
|
219
|
+
} else {
|
|
220
|
+
console.error(`\n✓ ${topMatches.length} vdoc match(es) found:\n`);
|
|
221
|
+
for (const match of topMatches) {
|
|
222
|
+
console.error(` ${match.filepath} (score: ${match.score})`);
|
|
223
|
+
console.error(` ${match.reasons.join(' | ')}`);
|
|
224
|
+
}
|
|
225
|
+
if (blastRadius.length > 0) {
|
|
226
|
+
console.error(`\n⚠ Blast radius — also affected:`);
|
|
227
|
+
for (const br of blastRadius) {
|
|
228
|
+
console.error(` ${br.feature} (via ${br.triggeredBy})`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
console.error('');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
function findFiles(dir, pattern) {
|
|
238
|
+
const results = [];
|
|
239
|
+
if (!fs.existsSync(dir)) return results;
|
|
240
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
241
|
+
for (const e of entries) {
|
|
242
|
+
const full = path.join(dir, e.name);
|
|
243
|
+
if (e.isDirectory()) results.push(...findFiles(full, pattern));
|
|
244
|
+
else if (pattern.test(e.name)) results.push(full);
|
|
245
|
+
}
|
|
246
|
+
return results;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractKeyFiles(doc) {
|
|
250
|
+
// From manifest entry — check if keyFiles exist in frontmatter-style
|
|
251
|
+
const files = [];
|
|
252
|
+
if (doc.keyFiles) files.push(...doc.keyFiles);
|
|
253
|
+
|
|
254
|
+
// Also try reading the doc to get Key Files table
|
|
255
|
+
const docPath = path.join(VDOCS_DIR, doc.filepath);
|
|
256
|
+
if (fs.existsSync(docPath)) {
|
|
257
|
+
const content = fs.readFileSync(docPath, 'utf8');
|
|
258
|
+
const keyFilesMatch = content.match(/## Key Files[\s\S]*?\|[^|]+\|[^|]+\|[^|]+\|([\s\S]*?)(?=\n---|\n##)/);
|
|
259
|
+
if (keyFilesMatch) {
|
|
260
|
+
const rows = keyFilesMatch[1].split('\n').filter(r => r.includes('|'));
|
|
261
|
+
for (const row of rows) {
|
|
262
|
+
const cells = row.split('|').map(c => c.trim());
|
|
263
|
+
const filePath = cells[1]?.replace(/`/g, '');
|
|
264
|
+
if (filePath && filePath.includes('/')) files.push(filePath);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return files;
|
|
269
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* vdoc_staleness.mjs
|
|
5
|
+
* Cross-references Dev Reports' files_modified against vdoc manifest key files.
|
|
6
|
+
* Generates a targeted Scribe task file listing stale docs.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ./scripts/vdoc_staleness.mjs S-05
|
|
10
|
+
*
|
|
11
|
+
* Output: .bounce/scribe-task-S-05.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import yaml from 'js-yaml';
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
21
|
+
|
|
22
|
+
const sprintId = process.argv[2];
|
|
23
|
+
if (!sprintId) {
|
|
24
|
+
console.error('Usage: vdoc_staleness.mjs S-XX');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VDOCS_DIR = path.join(ROOT, 'vdocs');
|
|
29
|
+
const MANIFEST_PATH = path.join(VDOCS_DIR, '_manifest.json');
|
|
30
|
+
|
|
31
|
+
// ── Check manifest ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(MANIFEST_PATH)) {
|
|
34
|
+
console.log('No vdocs/_manifest.json found — skipping staleness check');
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
39
|
+
const docs = manifest.documentation || [];
|
|
40
|
+
|
|
41
|
+
// ── Collect all files_modified from Dev Reports ───────────────────
|
|
42
|
+
|
|
43
|
+
const allModifiedFiles = new Set();
|
|
44
|
+
const storyModifications = {}; // storyId -> files
|
|
45
|
+
|
|
46
|
+
function findDevReports(dir) {
|
|
47
|
+
if (!fs.existsSync(dir)) return [];
|
|
48
|
+
const results = [];
|
|
49
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const full = path.join(dir, e.name);
|
|
52
|
+
if (e.isDirectory()) results.push(...findDevReports(full));
|
|
53
|
+
else if (e.name.endsWith('-dev.md')) results.push(full);
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const reportDirs = [
|
|
59
|
+
path.join(ROOT, '.bounce', 'reports'),
|
|
60
|
+
path.join(ROOT, '.bounce', 'archive', sprintId),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (const dir of reportDirs) {
|
|
64
|
+
const reports = findDevReports(dir);
|
|
65
|
+
for (const report of reports) {
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(report, 'utf8');
|
|
68
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
69
|
+
if (!fmMatch) continue;
|
|
70
|
+
const fm = yaml.load(fmMatch[1]) || {};
|
|
71
|
+
const files = fm.files_modified || [];
|
|
72
|
+
const storyMatch = path.basename(report).match(/STORY-[\w-]+/);
|
|
73
|
+
const storyId = storyMatch ? storyMatch[0] : 'unknown';
|
|
74
|
+
storyModifications[storyId] = files;
|
|
75
|
+
files.forEach(f => allModifiedFiles.add(f));
|
|
76
|
+
} catch { /* skip malformed reports */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (allModifiedFiles.size === 0) {
|
|
81
|
+
console.log('No files_modified found in Dev Reports — nothing to check');
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Cross-reference against manifest key files ────────────────────
|
|
86
|
+
|
|
87
|
+
const staleDocs = [];
|
|
88
|
+
|
|
89
|
+
for (const doc of docs) {
|
|
90
|
+
// Get key files from doc frontmatter or Key Files section
|
|
91
|
+
const docKeyFiles = [];
|
|
92
|
+
|
|
93
|
+
// From manifest entry
|
|
94
|
+
if (doc.keyFiles) docKeyFiles.push(...doc.keyFiles);
|
|
95
|
+
|
|
96
|
+
// From the doc file itself
|
|
97
|
+
const docPath = path.join(VDOCS_DIR, doc.filepath);
|
|
98
|
+
if (fs.existsSync(docPath)) {
|
|
99
|
+
const content = fs.readFileSync(docPath, 'utf8');
|
|
100
|
+
|
|
101
|
+
// Parse frontmatter keyFiles
|
|
102
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
103
|
+
if (fmMatch) {
|
|
104
|
+
try {
|
|
105
|
+
const fm = yaml.load(fmMatch[1]) || {};
|
|
106
|
+
if (fm.keyFiles) docKeyFiles.push(...fm.keyFiles);
|
|
107
|
+
} catch { /* ignore */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse Key Files table
|
|
111
|
+
const tableMatch = content.match(/## Key Files[\s\S]*?\|[^|]+\|[^|]+\|[^|]+\|([\s\S]*?)(?=\n---|\n##)/);
|
|
112
|
+
if (tableMatch) {
|
|
113
|
+
const rows = tableMatch[1].split('\n').filter(r => r.includes('|'));
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
const cells = row.split('|').map(c => c.trim());
|
|
116
|
+
const filePath = cells[1]?.replace(/`/g, '');
|
|
117
|
+
if (filePath && filePath.includes('/')) docKeyFiles.push(filePath);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check for overlap
|
|
123
|
+
const uniqueKeyFiles = [...new Set(docKeyFiles)];
|
|
124
|
+
const overlapping = uniqueKeyFiles.filter(kf =>
|
|
125
|
+
[...allModifiedFiles].some(mf => mf.includes(kf) || kf.includes(mf))
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (overlapping.length > 0) {
|
|
129
|
+
// Find which stories touched these files
|
|
130
|
+
const touchedBy = [];
|
|
131
|
+
for (const [sid, files] of Object.entries(storyModifications)) {
|
|
132
|
+
if (files.some(f => overlapping.some(o => f.includes(o) || o.includes(f)))) {
|
|
133
|
+
touchedBy.push(sid);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
staleDocs.push({
|
|
138
|
+
filepath: doc.filepath,
|
|
139
|
+
title: doc.title,
|
|
140
|
+
overlappingFiles: overlapping,
|
|
141
|
+
touchedBy,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (staleDocs.length === 0) {
|
|
147
|
+
console.log('✓ No stale vdocs detected — all docs are current');
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Generate Scribe task file ─────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const taskLines = [
|
|
154
|
+
`---`,
|
|
155
|
+
`sprint_id: "${sprintId}"`,
|
|
156
|
+
`mode: "audit"`,
|
|
157
|
+
`stale_docs: ${staleDocs.length}`,
|
|
158
|
+
`generated: "${new Date().toISOString().split('T')[0]}"`,
|
|
159
|
+
`---`,
|
|
160
|
+
``,
|
|
161
|
+
`# Scribe Task: ${sprintId} — Targeted Doc Update`,
|
|
162
|
+
``,
|
|
163
|
+
`> Auto-generated by staleness detection. ${staleDocs.length} doc(s) need updating.`,
|
|
164
|
+
``,
|
|
165
|
+
`## Stale Documents`,
|
|
166
|
+
``,
|
|
167
|
+
`| Doc | Title | Files Modified | Touched By |`,
|
|
168
|
+
`|-----|-------|---------------|------------|`,
|
|
169
|
+
...staleDocs.map(d =>
|
|
170
|
+
`| ${d.filepath} | ${d.title} | ${d.overlappingFiles.slice(0, 3).join(', ')}${d.overlappingFiles.length > 3 ? ` (+${d.overlappingFiles.length - 3})` : ''} | ${d.touchedBy.join(', ')} |`
|
|
171
|
+
),
|
|
172
|
+
``,
|
|
173
|
+
`## Instructions`,
|
|
174
|
+
``,
|
|
175
|
+
`For each stale doc:`,
|
|
176
|
+
`1. Read the current doc and the Dev Reports from the stories listed above`,
|
|
177
|
+
`2. Re-read the modified source files to understand what changed`,
|
|
178
|
+
`3. Update only the affected sections — do not rewrite the entire doc`,
|
|
179
|
+
`4. Bump frontmatter \`version\` (increment by 1) and set \`lastUpdated\` to today`,
|
|
180
|
+
`5. Update the manifest entry if tags or description need adjustment`,
|
|
181
|
+
`6. Regenerate the context slice in \`vdocs/_slices/\``,
|
|
182
|
+
``,
|
|
183
|
+
`## Priority`,
|
|
184
|
+
``,
|
|
185
|
+
...staleDocs.map(d => {
|
|
186
|
+
const fileCount = d.overlappingFiles.length;
|
|
187
|
+
const priority = fileCount >= 3 ? 'HIGH' : fileCount >= 2 ? 'MEDIUM' : 'LOW';
|
|
188
|
+
return `- **${d.filepath}**: ${priority} (${fileCount} key file(s) modified)`;
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const taskFile = path.join(ROOT, '.bounce', `scribe-task-${sprintId}.md`);
|
|
193
|
+
fs.writeFileSync(taskFile, taskLines.join('\n'));
|
|
194
|
+
|
|
195
|
+
console.log(`✓ Scribe task written to .bounce/scribe-task-${sprintId}.md`);
|
|
196
|
+
console.log(` ${staleDocs.length} stale doc(s) detected:`);
|
|
197
|
+
for (const d of staleDocs) {
|
|
198
|
+
console.log(` - ${d.filepath} (${d.overlappingFiles.length} key files modified by ${d.touchedBy.join(', ')})`);
|
|
199
|
+
}
|
|
@@ -359,17 +359,24 @@ After ALL stories are merged into `sprint/S-01`:
|
|
|
359
359
|
- Offer to run the `improve` skill to propose framework changes
|
|
360
360
|
- If user approves → read `skills/improve/SKILL.md` and execute the improvement process
|
|
361
361
|
8. Product Documentation check (runs on `main` after sprint merge):
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
362
|
+
a. **Staleness Detection** — run `./scripts/vdoc_staleness.mjs S-{XX}`
|
|
363
|
+
- Cross-references all Dev Reports' `files_modified` against manifest key files
|
|
364
|
+
- Generates `.bounce/scribe-task-S-{XX}.md` with targeted list of stale docs
|
|
365
|
+
- Populates Sprint Report §1 "Product Docs Affected" table
|
|
366
|
+
- If no `vdocs/_manifest.json` exists → skip silently (graceful no-op)
|
|
367
|
+
b. **Scribe Task Decision:**
|
|
368
|
+
- If staleness detection found stale docs → offer targeted Scribe task
|
|
369
|
+
- If sprint delivered 3+ features and no vdocs exist → offer vdoc init
|
|
370
|
+
- If any Developer report flagged stale product docs → offer Scribe update
|
|
371
|
+
c. If user approves → spawn scribe subagent on `main` branch with:
|
|
372
|
+
- `.bounce/scribe-task-S-{XX}.md` (targeted task — when available)
|
|
373
|
+
- Sprint Report (what was built)
|
|
374
|
+
- Dev reports that flagged affected product docs
|
|
375
|
+
- Current _manifest.json (if exists)
|
|
376
|
+
- Mode: "audit" (if docs exist) or "init" (if first time)
|
|
377
|
+
d. Scribe generates/updates docs and writes Scribe Report
|
|
378
|
+
- Documentation is post-implementation — it reflects what was built
|
|
379
|
+
- Scribe commits documentation as a follow-up commit on `main`
|
|
373
380
|
```
|
|
374
381
|
|
|
375
382
|
---
|
|
@@ -54,9 +54,13 @@ delivery_plan_ref: "product_plans/{delivery}/DELIVERY_PLAN.md"
|
|
|
54
54
|
- {e.g., "STORY-001-03-email_notifications — Escalated (template integration failed 3x)"}
|
|
55
55
|
|
|
56
56
|
### Product Docs Affected
|
|
57
|
-
>
|
|
57
|
+
> Auto-populated from staleness detection (`vbounce docs check S-{XX}`). Shows which vdocs were impacted by this sprint's code changes. Scribe agent receives a targeted task from `.bounce/scribe-task-S-{XX}.md`.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
| Doc | Stale Key Files | Touched By | Priority | Scribe Action |
|
|
60
|
+
|-----|----------------|------------|----------|---------------|
|
|
61
|
+
| {e.g., `AUTHENTICATION_DOC.md`} | {e.g., `src/auth/index.ts`} | {e.g., `STORY-001-02`} | {HIGH/MEDIUM/LOW} | {Update / Quality Fix / New} |
|
|
62
|
+
|
|
63
|
+
> If no `vdocs/_manifest.json` exists, write "N/A — vdoc not installed".
|
|
60
64
|
|
|
61
65
|
---
|
|
62
66
|
|