vibepro 0.1.0-alpha.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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +9 -0
  3. package/README.ja.md +448 -0
  4. package/README.md +520 -0
  5. package/agent-instructions/codex/AGENTS.vibepro.md +45 -0
  6. package/bin/vibepro.js +9 -0
  7. package/docs/assets/vibepro-header.png +0 -0
  8. package/package.json +51 -0
  9. package/skills/vibepro-diagnosis-packages/SKILL.md +133 -0
  10. package/skills/vibepro-human-review/SKILL.md +73 -0
  11. package/skills/vibepro-story-refactor/SKILL.md +89 -0
  12. package/skills/vibepro-workflow/SKILL.md +139 -0
  13. package/src/agent-harness-map.js +230 -0
  14. package/src/agent-harness-scanner.js +337 -0
  15. package/src/agent-review.js +2180 -0
  16. package/src/api-boundary-scanner.js +452 -0
  17. package/src/architecture-profiler.js +423 -0
  18. package/src/authorization-scoring.js +149 -0
  19. package/src/brainbase-importer.js +534 -0
  20. package/src/change-risk-classifier.js +195 -0
  21. package/src/check-packs.js +605 -0
  22. package/src/checkpoint-manager.js +233 -0
  23. package/src/cli.js +2213 -0
  24. package/src/code-quality-scanner.js +310 -0
  25. package/src/codex-manager.js +143 -0
  26. package/src/component-style-scanner.js +336 -0
  27. package/src/coverage-report.js +99 -0
  28. package/src/database-access-scanner.js +163 -0
  29. package/src/decision-records.js +315 -0
  30. package/src/design-modernize.js +1435 -0
  31. package/src/design-system.js +1732 -0
  32. package/src/diagnostic-engine.js +1945 -0
  33. package/src/diagram-requirement-resolver.js +194 -0
  34. package/src/doctor.js +677 -0
  35. package/src/environment-graph.js +424 -0
  36. package/src/execution-state.js +849 -0
  37. package/src/explore-evidence.js +425 -0
  38. package/src/flow-design-scanner.js +896 -0
  39. package/src/flow-verifier.js +887 -0
  40. package/src/gesture-interaction-scanner.js +330 -0
  41. package/src/graph-context.js +263 -0
  42. package/src/graphify-adapter.js +189 -0
  43. package/src/html-report.js +1035 -0
  44. package/src/journey-map.js +1299 -0
  45. package/src/language.js +48 -0
  46. package/src/lazy-pattern-detector.js +182 -0
  47. package/src/local-dev-scanner.js +135 -0
  48. package/src/managed-worktree-gate.js +187 -0
  49. package/src/managed-worktree.js +766 -0
  50. package/src/merge-manager.js +501 -0
  51. package/src/network-contract-scanner.js +442 -0
  52. package/src/nocodb-story-sync.js +386 -0
  53. package/src/oss-readiness-scanner.js +417 -0
  54. package/src/performance-evidence.js +756 -0
  55. package/src/performance-measurer.js +591 -0
  56. package/src/pr-manager.js +8220 -0
  57. package/src/presets.js +682 -0
  58. package/src/public-discovery-scanner.js +519 -0
  59. package/src/refactoring-delta-reporter.js +367 -0
  60. package/src/refactoring-opportunity-generator.js +797 -0
  61. package/src/regression-risk-scanner.js +146 -0
  62. package/src/repo-status.js +266 -0
  63. package/src/report-fingerprint.js +188 -0
  64. package/src/report-pr-body-prompt-template.md +108 -0
  65. package/src/report-pr-body-schema.json +95 -0
  66. package/src/report-store.js +135 -0
  67. package/src/report-validator.js +192 -0
  68. package/src/requirement-consistency.js +1066 -0
  69. package/src/runtime-info.js +134 -0
  70. package/src/self-dogfood-scanner.js +476 -0
  71. package/src/session-learning.js +164 -0
  72. package/src/skills-manager.js +157 -0
  73. package/src/spec-drift.js +378 -0
  74. package/src/spec-fingerprint.js +445 -0
  75. package/src/spec-prompt-template.md +155 -0
  76. package/src/spec-schema.json +219 -0
  77. package/src/spec-store.js +258 -0
  78. package/src/spec-validator.js +459 -0
  79. package/src/static-site-scanner.js +316 -0
  80. package/src/story-candidate-generator.js +85 -0
  81. package/src/story-catalog-generator.js +2813 -0
  82. package/src/story-html.js +156 -0
  83. package/src/story-manager.js +2144 -0
  84. package/src/story-task-generator.js +522 -0
  85. package/src/task-manager.js +1029 -0
  86. package/src/terminal-link-scanner.js +238 -0
  87. package/src/usage-report.js +417 -0
  88. package/src/verification-evidence.js +284 -0
  89. package/src/workspace.js +126 -0
@@ -0,0 +1,146 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ extractGraphNodeSourceFile,
6
+ getEdgeEndpoint,
7
+ normalizeGraphEdges,
8
+ normalizeGraphPath
9
+ } from './graph-context.js';
10
+ import { loadCoverage } from './coverage-report.js';
11
+ import { getWorkspaceDir } from './workspace.js';
12
+
13
+ // Regression-risk scanner: blast-radius core, optionally sharpened by coverage.
14
+ //
15
+ // Primary signal: module-level fan-in derived from the Graphify call graph. A
16
+ // module called by many distinct other modules has a large blast radius —
17
+ // changing it risks regressions in every caller. This is IMPACT risk, not defect
18
+ // probability: a static call graph cannot tell you a change is buggy, only how
19
+ // far its effects can reach. We name it accordingly and never claim prediction.
20
+ //
21
+ // Secondary signal (optional): real line coverage from the project's own tooling
22
+ // (c8/istanbul/lcov). When present, a high-blast-radius module with low coverage
23
+ // is escalated to `critical` — large reach AND a thin safety net is the genuine
24
+ // regression trap. Coverage is deliberately NOT inferred from the call graph:
25
+ // dogfooding VibePro showed static call-graph test-reachability mislabels
26
+ // CLI/subprocess-driven coverage as untested. When no coverage report exists we
27
+ // degrade cleanly to fan-in-only scoring with identical behavior to before.
28
+
29
+ const DEFAULT_HIGH_FAN_IN = 10;
30
+ const DEFAULT_MODERATE_FAN_IN = 5;
31
+ const DEFAULT_LOW_COVERAGE = 0.5;
32
+ const DEFAULT_TOP = 20;
33
+ const CALL_RELATIONS = new Set(['calls', 'call', 'invokes', 'uses']);
34
+
35
+ export async function scanRegressionRisk(repoRoot, options = {}) {
36
+ const root = path.resolve(repoRoot);
37
+ const graphPath = path.join(getWorkspaceDir(root), 'graphify', 'graph.json');
38
+
39
+ let graph;
40
+ try {
41
+ graph = JSON.parse(await readFile(graphPath, 'utf8'));
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') {
44
+ return {
45
+ status: 'skipped',
46
+ reason:
47
+ 'Regression-risk scanning needs a Graphify call graph at .vibepro/graphify/graph.json. ' +
48
+ 'Graphify is optional: run a story diagnosis or import with --run-graphify / --from <graphify-out> first.',
49
+ hotspots: [],
50
+ summary: { scored_modules: 0, high: 0, moderate: 0 }
51
+ };
52
+ }
53
+ throw error;
54
+ }
55
+
56
+ const loaded = await loadCoverage(root, { file: options.coverageFile });
57
+ return analyzeRegressionRisk(graph, {
58
+ ...options,
59
+ coverage: loaded?.coverage ?? null,
60
+ coverageSource: loaded?.source ?? null
61
+ });
62
+ }
63
+
64
+ // Pure analysis over a loaded graph. Exported for unit testing without disk I/O.
65
+ export function analyzeRegressionRisk(graph, options = {}) {
66
+ const includePrefixes = options.includePrefixes ?? ['src/'];
67
+ const highFanIn = options.highFanIn ?? DEFAULT_HIGH_FAN_IN;
68
+ const moderateFanIn = options.moderateFanIn ?? DEFAULT_MODERATE_FAN_IN;
69
+ const lowCoverage = options.lowCoverage ?? DEFAULT_LOW_COVERAGE;
70
+ const coverage = options.coverage ?? null;
71
+ const hasCoverage = coverage instanceof Map && coverage.size > 0;
72
+ const top = options.top ?? DEFAULT_TOP;
73
+
74
+ const fileById = new Map();
75
+ for (const node of graph?.nodes ?? []) {
76
+ if (!node || typeof node.id !== 'string') continue;
77
+ const sourceFile = extractGraphNodeSourceFile(node);
78
+ if (sourceFile) fileById.set(node.id, normalizeGraphPath(sourceFile));
79
+ }
80
+
81
+ const { edges } = normalizeGraphEdges(graph);
82
+ const callers = new Map(); // module -> Set of caller modules (fan-in)
83
+ const callees = new Map(); // module -> Set of callee modules (fan-out)
84
+ const inScope = (file) => Boolean(file) && includePrefixes.some((prefix) => file.startsWith(prefix));
85
+ const add = (map, key, value) => {
86
+ if (!map.has(key)) map.set(key, new Set());
87
+ map.get(key).add(value);
88
+ };
89
+
90
+ for (const edge of edges) {
91
+ if (edge?.relation && !CALL_RELATIONS.has(edge.relation)) continue;
92
+ const sourceFile = fileById.get(getEdgeEndpoint(edge, 'source'));
93
+ const targetFile = fileById.get(getEdgeEndpoint(edge, 'target'));
94
+ if (!sourceFile || !targetFile || sourceFile === targetFile) continue; // cross-module only
95
+ if (inScope(targetFile)) add(callers, targetFile, sourceFile);
96
+ if (inScope(sourceFile)) add(callees, sourceFile, targetFile);
97
+ }
98
+
99
+ const hotspots = [...callers.entries()]
100
+ .map(([file, callerSet]) => {
101
+ const fanIn = callerSet.size;
102
+ const riskTier = fanIn >= highFanIn ? 'high' : fanIn >= moderateFanIn ? 'moderate' : 'low';
103
+ const coveragePct = hasCoverage && coverage.has(file)
104
+ ? Number((coverage.get(file) * 100).toFixed(1))
105
+ : null;
106
+ // critical = large blast radius AND a thin (known) safety net.
107
+ const priority = riskTier === 'high' && coveragePct !== null && coveragePct < lowCoverage * 100
108
+ ? 'critical'
109
+ : riskTier;
110
+ return {
111
+ file,
112
+ fan_in: fanIn,
113
+ fan_out: callees.get(file)?.size ?? 0,
114
+ risk_tier: riskTier,
115
+ coverage_pct: coveragePct,
116
+ priority
117
+ };
118
+ })
119
+ .sort((a, b) => priorityRank(b) - priorityRank(a) || b.fan_in - a.fan_in || a.file.localeCompare(b.file));
120
+
121
+ const high = hotspots.filter((h) => h.risk_tier === 'high').length;
122
+ const moderate = hotspots.filter((h) => h.risk_tier === 'moderate').length;
123
+ const critical = hotspots.filter((h) => h.priority === 'critical').length;
124
+
125
+ // With coverage data, a well-tested hub is no longer a review trigger; only
126
+ // high-blast-radius + low-coverage (critical) escalates. Without coverage,
127
+ // behavior is unchanged: any high-blast-radius module triggers review.
128
+ const status = hasCoverage ? (critical > 0 ? 'needs_review' : 'pass') : (high > 0 ? 'needs_review' : 'pass');
129
+
130
+ return {
131
+ status,
132
+ hotspots: hotspots.slice(0, top),
133
+ summary: {
134
+ scored_modules: hotspots.length,
135
+ high,
136
+ moderate,
137
+ critical,
138
+ coverage_source: options.coverageSource ?? (hasCoverage ? 'provided' : null),
139
+ thresholds: { high_fan_in: highFanIn, moderate_fan_in: moderateFanIn, low_coverage_pct: lowCoverage * 100 }
140
+ }
141
+ };
142
+ }
143
+
144
+ function priorityRank(hotspot) {
145
+ return { critical: 3, high: 2, moderate: 1, low: 0 }[hotspot.priority] ?? 0;
146
+ }
@@ -0,0 +1,266 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { runDoctor } from './doctor.js';
5
+ import { DEFAULT_BRAINBASE_STORIES, getWorkspaceDir, MANIFEST_FILE } from './workspace.js';
6
+
7
+ export async function getRepoStatus(repoRoot) {
8
+ const root = path.resolve(repoRoot);
9
+ const workspaceDir = getWorkspaceDir(root);
10
+ const configPath = path.join(workspaceDir, 'config.json');
11
+ const manifestPath = path.join(workspaceDir, MANIFEST_FILE);
12
+ const initialized = await exists(configPath) && await exists(manifestPath);
13
+
14
+ if (!initialized) {
15
+ return {
16
+ initialized: false,
17
+ repo_root: root,
18
+ workspace: '.vibepro',
19
+ current_story_id: null,
20
+ active_stories: [],
21
+ latest_run: null,
22
+ selected_story_latest_run: null,
23
+ gate_status: null,
24
+ finding_count: 0,
25
+ artifacts: {},
26
+ next_commands: [`vibepro init ${root}`]
27
+ };
28
+ }
29
+
30
+ const configRead = await readJsonWithStatus(configPath, 'config');
31
+ const manifestRead = await readJsonWithStatus(manifestPath, 'manifest');
32
+ if (!configRead.ok || !manifestRead.ok) {
33
+ const issues = [configRead, manifestRead]
34
+ .filter((item) => !item.ok)
35
+ .map((item) => ({
36
+ id: `VP-STATUS-${item.label.toUpperCase()}-INVALID`,
37
+ severity: 'block',
38
+ file: toWorkspacePath(root, item.filePath),
39
+ detail: `${item.label} JSON is invalid: ${item.error}`
40
+ }));
41
+ return {
42
+ initialized: true,
43
+ repo_root: root,
44
+ workspace: '.vibepro',
45
+ workspace_status: 'needs_repair',
46
+ current_story_id: null,
47
+ active_stories: [],
48
+ latest_run: null,
49
+ selected_story_latest_run: null,
50
+ gate_status: 'blocked',
51
+ finding_count: issues.length,
52
+ artifacts: {},
53
+ doctor: {
54
+ overall_status: 'needs_repair',
55
+ check_count: issues.length,
56
+ blocking_check_ids: issues.map((issue) => issue.id),
57
+ next_actions: [{
58
+ command: `repair ${toWorkspacePath(root, configRead.ok ? manifestPath : configPath)}`,
59
+ reason: 'VibePro workspace JSON must be valid before status, diagnose, or PR gates can be trusted.'
60
+ }]
61
+ },
62
+ issues,
63
+ next_commands: [
64
+ `repair ${toWorkspacePath(root, configRead.ok ? manifestPath : configPath)}`,
65
+ `vibepro status ${root} --json`
66
+ ]
67
+ };
68
+ }
69
+
70
+ const config = configRead.value;
71
+ const manifest = manifestRead.value;
72
+ const currentStoryId = config.brainbase?.current_story_id ?? null;
73
+ const activeStories = prioritizeStory(normalizeStatusStories(config.brainbase?.stories), currentStoryId);
74
+ const selectedStory = activeStories.find((story) => story.story_id === currentStoryId) ?? null;
75
+ const runs = Array.isArray(manifest.runs) ? manifest.runs : [];
76
+ const latestRun = findRun(runs, manifest.latest_run) ?? runs[0] ?? null;
77
+ const selectedStoryLatestRun = selectedStory
78
+ ? findLatestStoryRun(manifest, runs, selectedStory.story_id)
79
+ : null;
80
+ const primaryRun = selectedStoryLatestRun ?? latestRun;
81
+ const evidence = primaryRun ? await readRunEvidence(root, primaryRun) : null;
82
+ const findings = Array.isArray(evidence?.findings) ? evidence.findings : [];
83
+ const doctor = await runDoctor(root, { writeArtifacts: false });
84
+
85
+ return {
86
+ initialized: true,
87
+ repo_root: root,
88
+ workspace: '.vibepro',
89
+ current_story_id: currentStoryId,
90
+ active_stories: activeStories,
91
+ latest_run: latestRun,
92
+ selected_story_latest_run: selectedStoryLatestRun,
93
+ gate_status: primaryRun?.gate_status ?? evidence?.gates?.[0]?.status ?? null,
94
+ finding_count: findings.length,
95
+ artifacts: primaryRun?.artifacts ?? {},
96
+ doctor: {
97
+ overall_status: doctor.overall_status,
98
+ check_count: doctor.checks.length,
99
+ blocking_check_ids: doctor.checks
100
+ .filter((check) => ['fixable', 'manual'].includes(check.status))
101
+ .map((check) => check.id),
102
+ next_actions: doctor.next_actions
103
+ },
104
+ next_commands: buildNextCommands(root, {
105
+ activeStories,
106
+ selectedStory,
107
+ latestRun,
108
+ selectedStoryLatestRun,
109
+ doctor
110
+ })
111
+ };
112
+ }
113
+
114
+ export function renderRepoStatus(status) {
115
+ const latestRun = status.latest_run;
116
+ const selectedStoryRun = status.selected_story_latest_run;
117
+ return `# VibePro Status
118
+
119
+ | 項目 | 内容 |
120
+ |------|------|
121
+ | Initialized | ${status.initialized ? 'yes' : 'no'} |
122
+ | Workspace | ${status.workspace} |
123
+ | Workspace Status | ${status.workspace_status ?? 'ok'} |
124
+ | Selected Story | ${status.current_story_id ?? '-'} |
125
+ | Active Stories | ${status.active_stories.length} |
126
+ | Latest Run | ${latestRun?.run_id ?? '-'} |
127
+ | Selected Story Latest Run | ${selectedStoryRun?.run_id ?? '-'} |
128
+ | Gate | ${status.gate_status ?? '-'} |
129
+ | Findings | ${status.finding_count} |
130
+ | Doctor | ${status.doctor?.overall_status ?? '-'} |
131
+
132
+ ## Active Stories
133
+
134
+ ${status.active_stories.length === 0 ? '- なし' : status.active_stories.map((story) => `- ${story.story_id}: ${story.title} / view:${story.view ?? '-'} / period:${story.period ?? '-'}`).join('\n')}
135
+
136
+ ## Artifacts
137
+
138
+ ${Object.entries(status.artifacts).length === 0 ? '- なし' : Object.entries(status.artifacts).map(([key, value]) => `- ${key}: ${value}`).join('\n')}
139
+
140
+ ## Doctor
141
+
142
+ ${renderDoctorStatus(status.doctor)}
143
+
144
+ ## Next Commands
145
+
146
+ ${status.next_commands.map((command) => `- ${command}`).join('\n')}
147
+ `;
148
+ }
149
+
150
+ async function exists(filePath) {
151
+ try {
152
+ await access(filePath);
153
+ return true;
154
+ } catch (error) {
155
+ if (error.code === 'ENOENT') return false;
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ async function readJsonWithStatus(filePath, label) {
161
+ try {
162
+ return {
163
+ ok: true,
164
+ label,
165
+ filePath,
166
+ value: JSON.parse(await readFile(filePath, 'utf8'))
167
+ };
168
+ } catch (error) {
169
+ if (error instanceof SyntaxError) {
170
+ return {
171
+ ok: false,
172
+ label,
173
+ filePath,
174
+ error: error.message
175
+ };
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ function toWorkspacePath(repoRoot, filePath) {
182
+ return path.relative(repoRoot, filePath).split(path.sep).join('/');
183
+ }
184
+
185
+ function findRun(runs, runId) {
186
+ if (!runId) return null;
187
+ return runs.find((run) => run.run_id === runId) ?? null;
188
+ }
189
+
190
+ function prioritizeStory(stories, storyId) {
191
+ if (!storyId) return stories;
192
+ const selected = stories.find((story) => story.story_id === storyId);
193
+ if (!selected) return stories;
194
+ return [selected, ...stories.filter((story) => story.story_id !== storyId)];
195
+ }
196
+
197
+ function normalizeStatusStories(stories) {
198
+ const sourceStories = Array.isArray(stories) ? stories : DEFAULT_BRAINBASE_STORIES;
199
+ return sourceStories
200
+ .filter((story) => story.status !== 'archived')
201
+ .map((story) => ({
202
+ story_id: story.story_id,
203
+ title: story.title,
204
+ ssot: story.ssot ?? 'NocoDB',
205
+ status: story.status ?? 'active',
206
+ horizon: story.horizon ?? null,
207
+ view: typeof story.view === 'string' ? story.view : null,
208
+ period: typeof story.period === 'string' ? story.period : null,
209
+ started_at: story.started_at ?? null,
210
+ due_at: story.due_at ?? null
211
+ }));
212
+ }
213
+
214
+ function findLatestStoryRun(manifest, runs, storyId) {
215
+ const latestStoryRunId = manifest.latest_run_by_story?.[storyId] ?? null;
216
+ return findRun(runs, latestStoryRunId)
217
+ ?? runs.find((run) => run.story_id === storyId)
218
+ ?? null;
219
+ }
220
+
221
+ async function readRunEvidence(repoRoot, run) {
222
+ const evidencePath = run.artifacts?.evidence;
223
+ if (!evidencePath) return null;
224
+ try {
225
+ return JSON.parse(await readFile(path.resolve(repoRoot, evidencePath), 'utf8'));
226
+ } catch (error) {
227
+ if (error.code === 'ENOENT') return null;
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ function buildNextCommands(repoRoot, { activeStories, selectedStory, latestRun, selectedStoryLatestRun, doctor = null }) {
233
+ if (doctor && ['needs_maintenance', 'fixed'].includes(doctor.overall_status)) {
234
+ return doctor.next_commands?.length > 0
235
+ ? doctor.next_commands
236
+ : [`vibepro doctor ${repoRoot}`, `vibepro doctor ${repoRoot} --fix`];
237
+ }
238
+ if (activeStories.length === 0) {
239
+ return [`vibepro story add ${repoRoot} --id <story-id> --title "<title>"`];
240
+ }
241
+ if (!selectedStory) {
242
+ return [`vibepro story select ${repoRoot} --id ${activeStories[0].story_id}`];
243
+ }
244
+ if (!latestRun && !selectedStoryLatestRun) {
245
+ return [`vibepro story diagnose ${repoRoot} --id ${selectedStory.story_id} --run-graphify`];
246
+ }
247
+ return [
248
+ `vibepro story report ${repoRoot} --id ${selectedStory.story_id}`,
249
+ `vibepro brainbase ${repoRoot}`
250
+ ];
251
+ }
252
+
253
+ function renderDoctorStatus(doctor) {
254
+ if (!doctor) return '- なし';
255
+ const checks = doctor.blocking_check_ids?.length > 0
256
+ ? doctor.blocking_check_ids.join(', ')
257
+ : '-';
258
+ const actions = doctor.next_actions?.length > 0
259
+ ? doctor.next_actions.map((action) => ` - ${action.command}: ${action.reason}`).join('\n')
260
+ : ' - なし';
261
+ return `- overall: ${doctor.overall_status}
262
+ - checks: ${doctor.check_count}
263
+ - needs action: ${checks}
264
+ - next actions:
265
+ ${actions}`;
266
+ }
@@ -0,0 +1,188 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { preparePullRequest } from './pr-manager.js';
7
+ import { readDrift, readInferredSpec } from './spec-store.js';
8
+ import { readNarrative, REPORT_KINDS } from './report-store.js';
9
+ import { getWorkspaceDir } from './workspace.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ export async function buildReportFingerprint(repoRoot, options = {}) {
14
+ const root = path.resolve(repoRoot);
15
+ const kind = options.kind;
16
+ if (!REPORT_KINDS.has(kind)) {
17
+ throw new Error(`Unsupported report kind: ${kind}`);
18
+ }
19
+ const storyId = options.storyId ?? null;
20
+ if (!storyId) {
21
+ throw new Error('storyId is required');
22
+ }
23
+ if (kind === 'pr-body') {
24
+ return buildPrBodyFingerprint(root, { ...options, storyId });
25
+ }
26
+ throw new Error(`Unsupported report kind: ${kind}`);
27
+ }
28
+
29
+ async function buildPrBodyFingerprint(root, options) {
30
+ const storyId = options.storyId;
31
+ const prepare = await preparePullRequest(root, {
32
+ storyId,
33
+ baseRef: options.baseRef ?? null,
34
+ taskId: options.taskId ?? null,
35
+ groupId: options.groupId ?? null,
36
+ branchName: options.branchName ?? null,
37
+ allowExtraFiles: true
38
+ });
39
+
40
+ const preparation = prepare?.preparation ?? null;
41
+ const previousNarrative = await readNarrative(root, storyId, 'pr-body');
42
+ const inferredSpec = await readInferredSpec(root, storyId);
43
+ const drift = await readDrift(root, storyId);
44
+ const findings = await readLatestFindings(root, preparation?.latest_story_run);
45
+
46
+ const fingerprint = {
47
+ schema_version: '0.1.0',
48
+ kind: 'pr-body',
49
+ story_id: storyId,
50
+ generated_at: new Date().toISOString(),
51
+ story: preparation?.story ?? null,
52
+ pr_context: extractPrContextSummary(preparation),
53
+ file_groups: summarizeFileGroups(preparation?.file_groups),
54
+ gate_dag: summarizeGateDag(preparation?.pr_context?.gate_dag),
55
+ requirement_consistency: summarizeRequirement(preparation?.pr_context?.requirement_consistency),
56
+ inferred_spec: inferredSpec ? {
57
+ story_id: inferredSpec.story_id,
58
+ clauses: (inferredSpec.clauses ?? []).map((clause) => ({
59
+ id: clause.id,
60
+ type: clause.type,
61
+ statement: clause.statement
62
+ }))
63
+ } : null,
64
+ drift: drift ? {
65
+ status: drift.status,
66
+ summary: drift.summary,
67
+ items: (drift.items ?? []).map((item) => ({
68
+ id: item.id,
69
+ axis: item.axis,
70
+ clause_id: item.clause_id ?? null,
71
+ severity: item.severity,
72
+ title: item.title
73
+ }))
74
+ } : null,
75
+ findings: findings.map((finding) => ({
76
+ id: finding.id,
77
+ title: finding.title,
78
+ severity: finding.severity ?? null,
79
+ type: finding.type ?? null
80
+ })),
81
+ numerical_truth: buildNumericalTruth({ preparation, drift, requirementConsistency: preparation?.pr_context?.requirement_consistency }),
82
+ previous_narrative: previousNarrative,
83
+ schema_for_your_output: await readJson(path.join(__dirname, 'report-pr-body-schema.json')),
84
+ instructions: options.includeInstructions
85
+ ? await readFile(path.join(__dirname, 'report-pr-body-prompt-template.md'), 'utf8')
86
+ : null
87
+ };
88
+ fingerprint.inputs_digest = buildInputsDigest(fingerprint);
89
+ return fingerprint;
90
+ }
91
+
92
+ function extractPrContextSummary(preparation) {
93
+ if (!preparation) return null;
94
+ const ctx = preparation.pr_context ?? {};
95
+ return {
96
+ story_source_path: ctx.story_source?.path ?? null,
97
+ architecture_decision: ctx.architecture_decision ?? null,
98
+ change_summary: ctx.change_summary ?? [],
99
+ review_points: ctx.review_points ?? [],
100
+ risks: ctx.risks ?? [],
101
+ verification_commands: (ctx.verification_commands ?? []).map((entry) => ({
102
+ command: entry.command,
103
+ reason: entry.reason
104
+ }))
105
+ };
106
+ }
107
+
108
+ function summarizeFileGroups(fileGroups) {
109
+ if (!fileGroups) return null;
110
+ const summary = {};
111
+ for (const [key, value] of Object.entries(fileGroups)) {
112
+ summary[key] = {
113
+ count: value.count,
114
+ files: (value.files ?? []).slice(0, 12)
115
+ };
116
+ }
117
+ return summary;
118
+ }
119
+
120
+ function summarizeGateDag(gateDag) {
121
+ if (!gateDag) return null;
122
+ return {
123
+ overall_status: gateDag.overall_status ?? null,
124
+ nodes: (gateDag.nodes ?? []).map((node) => ({
125
+ id: node.id,
126
+ type: node.type,
127
+ status: node.status,
128
+ required: node.required ?? null,
129
+ reason: node.reason ?? null
130
+ }))
131
+ };
132
+ }
133
+
134
+ function summarizeRequirement(requirement) {
135
+ if (!requirement) return null;
136
+ return {
137
+ status: requirement.status,
138
+ summary: requirement.summary,
139
+ invariants: (requirement.invariants ?? []).map((entry) => ({ id: entry.id, text: entry.text })),
140
+ contradictions: (requirement.contradictions ?? []).map((entry) => ({ id: entry.id, title: entry.title })),
141
+ scenario_gaps: (requirement.scenario_gaps ?? []).map((entry) => ({ id: entry.id, title: entry.title }))
142
+ };
143
+ }
144
+
145
+ async function readLatestFindings(root, latestStoryRun) {
146
+ const evidencePath = latestStoryRun?.artifacts?.evidence;
147
+ if (!evidencePath) return [];
148
+ try {
149
+ const evidence = JSON.parse(await readFile(path.resolve(root, evidencePath), 'utf8'));
150
+ return Array.isArray(evidence.findings) ? evidence.findings.slice(0, 40) : [];
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ function buildNumericalTruth({ preparation, drift, requirementConsistency }) {
157
+ const driftItems = drift?.items ?? [];
158
+ return {
159
+ changed_files_count: preparation?.git?.changed_files?.length ?? 0,
160
+ drift_total_count: driftItems.length,
161
+ drift_high_count: driftItems.filter((item) => item.severity === 'high').length,
162
+ requirement_invariant_count: requirementConsistency?.summary?.invariant_count ?? 0,
163
+ requirement_contradiction_count: requirementConsistency?.summary?.contradiction_count ?? 0,
164
+ acceptance_criteria_count: preparation?.pr_context?.story_source?.acceptance_criteria?.length ?? 0
165
+ };
166
+ }
167
+
168
+ function buildInputsDigest(fingerprint) {
169
+ return {
170
+ story_sha: sha256(fingerprint.story),
171
+ pr_context_sha: sha256(fingerprint.pr_context),
172
+ drift_sha: sha256(fingerprint.drift),
173
+ spec_sha: sha256(fingerprint.inferred_spec)
174
+ };
175
+ }
176
+
177
+ function sha256(value) {
178
+ const hash = createHash('sha256');
179
+ hash.update(JSON.stringify(value ?? null));
180
+ return `sha256:${hash.digest('hex')}`;
181
+ }
182
+
183
+ async function readJson(filePath) {
184
+ return JSON.parse(await readFile(filePath, 'utf8'));
185
+ }
186
+
187
+ // re-export utility for tests
188
+ export { getWorkspaceDir };