@sandrinio/vbounce 1.7.0 → 1.9.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 (36) hide show
  1. package/README.md +168 -160
  2. package/bin/vbounce.mjs +250 -25
  3. package/brains/AGENTS.md +4 -4
  4. package/brains/CHANGELOG.md +2 -2
  5. package/brains/CLAUDE.md +4 -4
  6. package/brains/GEMINI.md +5 -5
  7. package/brains/SETUP.md +15 -15
  8. package/brains/claude-agents/architect.md +1 -1
  9. package/brains/claude-agents/developer.md +1 -1
  10. package/brains/claude-agents/devops.md +1 -1
  11. package/brains/claude-agents/qa.md +2 -1
  12. package/brains/claude-agents/scribe.md +1 -1
  13. package/brains/copilot/copilot-instructions.md +3 -3
  14. package/brains/cursor-rules/vbounce-docs.mdc +2 -2
  15. package/brains/cursor-rules/vbounce-process.mdc +3 -3
  16. package/brains/cursor-rules/vbounce-rules.mdc +2 -2
  17. package/brains/windsurf/.windsurfrules +2 -2
  18. package/docs/HOTFIX_EDGE_CASES.md +1 -1
  19. package/package.json +5 -5
  20. package/scripts/doctor.mjs +3 -3
  21. package/scripts/hotfix_manager.sh +2 -2
  22. package/scripts/init_gate_config.sh +1 -1
  23. package/scripts/pre_gate_common.sh +1 -1
  24. package/scripts/pre_gate_runner.sh +1 -1
  25. package/scripts/prep_qa_context.mjs +19 -1
  26. package/scripts/prep_sprint_context.mjs +24 -1
  27. package/scripts/suggest_improvements.mjs +1 -1
  28. package/scripts/validate_bounce_readiness.mjs +27 -0
  29. package/scripts/validate_report.mjs +1 -1
  30. package/scripts/vdoc_match.mjs +269 -0
  31. package/scripts/vdoc_staleness.mjs +199 -0
  32. package/scripts/verify_framework.mjs +1 -1
  33. package/skills/agent-team/SKILL.md +18 -11
  34. package/skills/doc-manager/SKILL.md +5 -5
  35. package/skills/improve/SKILL.md +2 -2
  36. package/templates/sprint_report.md +6 -2
@@ -1,6 +1,6 @@
1
1
  # Hotfix Workflow: Edge Cases & Mitigations
2
2
 
3
- This document outlines the critical edge cases, failure modes, and required mitigations for the **V-Bounce OS Hotfix (L1 Trivial)** workflow.
3
+ This document outlines the critical edge cases, failure modes, and required mitigations for the **V-Bounce Engine Hotfix (L1 Trivial)** workflow.
4
4
 
5
5
  ---
6
6
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sandrinio/vbounce",
3
- "version": "1.7.0",
4
- "description": "V-Bounce OS: Turn your AI coding assistant into a full engineering team through structured SDLC skills.",
3
+ "version": "1.9.0",
4
+ "description": "V-Bounce Engine: Turn your AI coding assistant into a full engineering team through structured SDLC skills.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "vbounce": "./bin/vbounce.mjs"
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://github.com/sandrinio/v-bounce-os.git"
14
+ "url": "git+https://github.com/sandrinio/v-bounce-engine.git"
15
15
  },
16
16
  "keywords": [
17
17
  "ai",
@@ -28,9 +28,9 @@
28
28
  "author": "sandrinio",
29
29
  "license": "MIT",
30
30
  "bugs": {
31
- "url": "https://github.com/sandrinio/v-bounce-os/issues"
31
+ "url": "https://github.com/sandrinio/v-bounce-engine/issues"
32
32
  },
33
- "homepage": "https://github.com/sandrinio/v-bounce-os#readme",
33
+ "homepage": "https://github.com/sandrinio/v-bounce-engine#readme",
34
34
  "files": [
35
35
  "bin",
36
36
  "brains",
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * doctor.mjs
5
- * V-Bounce OS Health Check — validates all configs, templates, state files
5
+ * V-Bounce Engine Health Check — validates all configs, templates, state files
6
6
  * Usage: vbounce doctor
7
7
  */
8
8
 
@@ -42,7 +42,7 @@ const templatesDir = path.join(ROOT, 'templates');
42
42
  let templateCount = 0;
43
43
  for (const t of requiredTemplates) {
44
44
  if (fs.existsSync(path.join(templatesDir, t))) templateCount++;
45
- else fail(`templates/${t} missing`, `Create from V-Bounce OS template`);
45
+ else fail(`templates/${t} missing`, `Create from V-Bounce Engine template`);
46
46
  }
47
47
  if (templateCount === requiredTemplates.length) pass(`templates/ complete (${templateCount}/${requiredTemplates.length})`);
48
48
 
@@ -130,7 +130,7 @@ if (fs.existsSync(path.join(ROOT, 'vbounce.config.json'))) {
130
130
  }
131
131
 
132
132
  // Print results
133
- console.log('\nV-Bounce OS Health Check');
133
+ console.log('\nV-Bounce Engine Health Check');
134
134
  console.log('========================');
135
135
  checks.forEach(c => console.log(c));
136
136
  console.log('');
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
 
3
- # V-Bounce OS: Hotfix Manager
3
+ # V-Bounce Engine: Hotfix Manager
4
4
  # Handles edge cases for L1 Trivial tasks to save tokens and ensure framework integrity.
5
5
 
6
6
  set -euo pipefail
@@ -14,7 +14,7 @@ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
14
14
  COMMAND="${1:-}"
15
15
 
16
16
  function show_help {
17
- echo "V-Bounce OS — Hotfix Manager"
17
+ echo "V-Bounce Engine — Hotfix Manager"
18
18
  echo ""
19
19
  echo "Usage: ./scripts/hotfix_manager.sh <command> [args]"
20
20
  echo ""
@@ -17,7 +17,7 @@ YELLOW='\033[1;33m'
17
17
  CYAN='\033[0;36m'
18
18
  NC='\033[0m'
19
19
 
20
- echo -e "${CYAN}V-Bounce OS Gate Config Initializer${NC}"
20
+ echo -e "${CYAN}V-Bounce Engine Gate Config Initializer${NC}"
21
21
  echo -e "Project: ${PROJECT_PATH}"
22
22
  echo ""
23
23
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # pre_gate_common.sh — Shared gate check functions for V-Bounce OS
2
+ # pre_gate_common.sh — Shared gate check functions for V-Bounce Engine
3
3
  # Sourced by pre_gate_runner.sh. Never run directly.
4
4
 
5
5
  set -euo pipefail
@@ -31,7 +31,7 @@ fi
31
31
  # Resolve to absolute path
32
32
  WORKTREE_PATH="$(cd "$WORKTREE_PATH" && pwd)"
33
33
 
34
- echo -e "${CYAN}V-Bounce OS Pre-Gate Scanner${NC}"
34
+ echo -e "${CYAN}V-Bounce Engine Pre-Gate Scanner${NC}"
35
35
  echo -e "Gate: ${YELLOW}${GATE_TYPE}${NC}"
36
36
  echo -e "Target: ${WORKTREE_PATH}"
37
37
  echo ""
@@ -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
  '',
@@ -159,7 +159,7 @@ suggestions.push({
159
159
  num: itemNum++,
160
160
  category: 'Health',
161
161
  title: 'Run vbounce doctor',
162
- detail: 'Verify the V-Bounce OS installation is healthy after this sprint.',
162
+ detail: 'Verify the V-Bounce Engine installation is healthy after this sprint.',
163
163
  recommendation: 'Run: `vbounce doctor` — checks brain files, templates, scripts, state.json validity.',
164
164
  target: 'scripts/doctor.mjs',
165
165
  effort: 'Trivial',
@@ -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('');
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * validate_report.mjs
5
5
  *
6
- * Strict YAML Frontmatter validation for V-Bounce OS Agent Reports.
6
+ * Strict YAML Frontmatter validation for V-Bounce Engine Agent Reports.
7
7
  * Fails loudly if an agent hallucinates formatting or omits required fields,
8
8
  * so the orchestrator can bounce the prompt back.
9
9
  */
@@ -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
+ }