@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandrinio/vbounce",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "V-Bounce OS: Turn your AI coding assistant into a full engineering team through structured SDLC skills.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. Assemble context pack
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. Assemble context pack
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
- - If sprint delivered 3+ features, or if any Developer report flagged
363
- stale product docs offer to run vdoc to generate/update
364
- vdocs/
365
- - If user approves spawn scribe subagent on `main` branch with:
366
- - Sprint Report (what was built)
367
- - Dev reports that flagged affected product docs
368
- - Current _manifest.json (if exists)
369
- - Mode: "audit" (if docs exist) or "init" (if first time)
370
- - Scribe generates/updates docs and writes Scribe Report
371
- - Documentation is post-implementation it reflects what was built
372
- - Scribe commits documentation as a follow-up commit on `main`
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
- > Any product documentation files modified, created, or identified for updates. Handed off to Scribe agent.
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
- - {e.g., "vdocs/api_reference.md Added rate limiting details"}
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