@sandrinio/vbounce 1.6.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.
Files changed (48) hide show
  1. package/README.md +108 -18
  2. package/bin/vbounce.mjs +306 -145
  3. package/brains/AGENTS.md +5 -5
  4. package/brains/CHANGELOG.md +88 -1
  5. package/brains/CLAUDE.md +22 -17
  6. package/brains/GEMINI.md +40 -4
  7. package/brains/SETUP.md +11 -5
  8. package/brains/claude-agents/architect.md +13 -6
  9. package/brains/claude-agents/developer.md +2 -2
  10. package/brains/claude-agents/qa.md +17 -7
  11. package/brains/copilot/copilot-instructions.md +49 -0
  12. package/brains/cursor-rules/vbounce-process.mdc +2 -2
  13. package/brains/windsurf/.windsurfrules +30 -0
  14. package/package.json +2 -4
  15. package/scripts/close_sprint.mjs +94 -0
  16. package/scripts/complete_story.mjs +113 -0
  17. package/scripts/doctor.mjs +144 -0
  18. package/scripts/init_sprint.mjs +121 -0
  19. package/scripts/prep_arch_context.mjs +178 -0
  20. package/scripts/prep_qa_context.mjs +152 -0
  21. package/scripts/prep_sprint_context.mjs +141 -0
  22. package/scripts/prep_sprint_summary.mjs +154 -0
  23. package/scripts/sprint_trends.mjs +160 -0
  24. package/scripts/suggest_improvements.mjs +200 -0
  25. package/scripts/update_state.mjs +132 -0
  26. package/scripts/validate_bounce_readiness.mjs +152 -0
  27. package/scripts/validate_report.mjs +39 -2
  28. package/scripts/validate_sprint_plan.mjs +117 -0
  29. package/scripts/validate_state.mjs +99 -0
  30. package/scripts/vdoc_match.mjs +269 -0
  31. package/scripts/vdoc_staleness.mjs +199 -0
  32. package/skills/agent-team/SKILL.md +53 -28
  33. package/skills/agent-team/references/cleanup.md +42 -0
  34. package/skills/agent-team/references/delivery-sync.md +43 -0
  35. package/skills/agent-team/references/git-strategy.md +52 -0
  36. package/skills/agent-team/references/mid-sprint-triage.md +71 -0
  37. package/skills/agent-team/references/report-naming.md +34 -0
  38. package/skills/doc-manager/SKILL.md +5 -4
  39. package/skills/improve/SKILL.md +1 -1
  40. package/skills/lesson/SKILL.md +23 -0
  41. package/templates/delivery_plan.md +1 -1
  42. package/templates/hotfix.md +1 -1
  43. package/templates/sprint.md +65 -13
  44. package/templates/sprint_report.md +14 -3
  45. package/templates/story.md +1 -1
  46. package/scripts/pre_bounce_sync.sh +0 -37
  47. package/scripts/vbounce_ask.mjs +0 -98
  48. package/scripts/vbounce_index.mjs +0 -184
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * validate_state.mjs
5
+ * Validates .bounce/state.json schema.
6
+ * Usage: ./scripts/validate_state.mjs
7
+ * Also exportable: import { validateState } from './validate_state.mjs'
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const ROOT = path.resolve(__dirname, '..');
16
+ const STATE_FILE = path.join(ROOT, '.bounce', 'state.json');
17
+
18
+ const VALID_STATES = [
19
+ 'Draft', 'Refinement', 'Ready to Bounce', 'Bouncing',
20
+ 'QA Passed', 'Architect Passed', 'Done', 'Escalated', 'Parking Lot'
21
+ ];
22
+
23
+ /**
24
+ * Validates a state object. Returns { valid, errors }.
25
+ * @param {object} state
26
+ * @returns {{ valid: boolean, errors: string[] }}
27
+ */
28
+ export function validateState(state) {
29
+ const errors = [];
30
+
31
+ if (!state || typeof state !== 'object') {
32
+ return { valid: false, errors: ['state.json must be a JSON object'] };
33
+ }
34
+
35
+ if (!state.sprint_id || !/^S-\d{2}$/.test(state.sprint_id)) {
36
+ errors.push(`sprint_id "${state.sprint_id}" must match S-XX format (e.g. S-05)`);
37
+ }
38
+
39
+ if (!state.delivery_id || !/^D-\d{2}$/.test(state.delivery_id)) {
40
+ errors.push(`delivery_id "${state.delivery_id}" must match D-NN format (e.g. D-02)`);
41
+ }
42
+
43
+ if (!state.stories || typeof state.stories !== 'object') {
44
+ errors.push('stories field must be an object');
45
+ } else {
46
+ for (const [id, story] of Object.entries(state.stories)) {
47
+ if (!VALID_STATES.includes(story.state)) {
48
+ errors.push(`Story ${id}: invalid state "${story.state}". Must be one of: ${VALID_STATES.join(', ')}`);
49
+ }
50
+ if (typeof story.qa_bounces !== 'number' || !Number.isInteger(story.qa_bounces) || story.qa_bounces < 0) {
51
+ errors.push(`Story ${id}: qa_bounces must be a non-negative integer, got "${story.qa_bounces}"`);
52
+ }
53
+ if (typeof story.arch_bounces !== 'number' || !Number.isInteger(story.arch_bounces) || story.arch_bounces < 0) {
54
+ errors.push(`Story ${id}: arch_bounces must be a non-negative integer, got "${story.arch_bounces}"`);
55
+ }
56
+ if (story.state === 'Done' && story.worktree) {
57
+ errors.push(`Story ${id}: state is "Done" but worktree "${story.worktree}" is still set (should be null)`);
58
+ }
59
+ }
60
+ }
61
+
62
+ if (state.updated_at) {
63
+ const d = new Date(state.updated_at);
64
+ if (isNaN(d.getTime())) {
65
+ errors.push(`updated_at "${state.updated_at}" is not a valid ISO 8601 timestamp`);
66
+ }
67
+ } else {
68
+ errors.push('updated_at field is required');
69
+ }
70
+
71
+ return { valid: errors.length === 0, errors };
72
+ }
73
+
74
+ // CLI entry point
75
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
76
+ if (!fs.existsSync(STATE_FILE)) {
77
+ console.error(`ERROR: ${STATE_FILE} not found. Run: vbounce sprint init S-XX D-XX`);
78
+ process.exit(1);
79
+ }
80
+
81
+ let state;
82
+ try {
83
+ state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
84
+ } catch (e) {
85
+ console.error(`ERROR: state.json is not valid JSON — ${e.message}`);
86
+ process.exit(1);
87
+ }
88
+
89
+ const { valid, errors } = validateState(state);
90
+
91
+ if (valid) {
92
+ console.log(`VALID: state.json — sprint ${state.sprint_id}, ${Object.keys(state.stories || {}).length} stories`);
93
+ process.exit(0);
94
+ } else {
95
+ console.error('INVALID: state.json has errors:');
96
+ errors.forEach(e => console.error(` - ${e}`));
97
+ process.exit(1);
98
+ }
99
+ }
@@ -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
+ }