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,108 @@
1
+ # VibePro PR-body narrative authoring (for the calling AI)
2
+
3
+ You are receiving a JSON payload from `vibepro report fingerprint --kind pr-body`.
4
+ Emit a JSON narrative back into `vibepro report write --kind pr-body --from-stdin`.
5
+
6
+ You do NOT write the whole PR body. VibePro keeps the skeleton (headings, file
7
+ lists, gate-dag, verification commands). You write the **prose that requires
8
+ judgment** — synthesis a template cannot do.
9
+
10
+ ## Four slots
11
+
12
+ 1. **`summary`** — 1 slot. 2–5 sentences. "What does this PR change and why
13
+ does it matter?" Lead with the user-visible effect, not the file count.
14
+ 2. **`review_focus`** — 1–5 slots, one bullet each. The 1–3 things a reviewer
15
+ should examine that are not obvious from the diff. Avoid generic advice
16
+ ("check tests"). Be specific: which file's invariant is brittle, which clause
17
+ is contested, which drift item is load-bearing.
18
+ 3. **`risks_synthesis`** — 1 slot, 1–3 sentences. Synthesize across the
19
+ mechanical risk list. "What is the dominant failure mode this PR
20
+ introduces?" If there is no real risk beyond the enumeration, output:
21
+ `text: "特記事項なし"`.
22
+ 4. **`open_questions`** — 0–5 slots, one item each. Things you could not
23
+ determine from Story+Code+Test+drift. Reviewer must adjudicate. Skip
24
+ entirely if nothing is open.
25
+
26
+ ## Rules
27
+
28
+ 1. **Cite, do not paraphrase silently.** If you mention a file, list it in
29
+ `citations.files`. If you reference a finding (`VP-TASK-001`), drift item
30
+ (`DRIFT-ABC123`), or clause (`INV-001`), list it in the matching
31
+ citations array. Validator rejects citations to objects that do not exist.
32
+ 2. **Numerical claims must use `numerical_claims[]`.** If you write
33
+ "drift items が 3 件", you must also declare
34
+ `{"field":"drift_total_count","value":3}`. Validator compares to fingerprint.
35
+ 3. **Reuse TP ids when possible.** If `previous_narrative` contains a slot
36
+ whose text is semantically equivalent, copy its id. Otherwise use
37
+ `TP-NEW-<n>` — validator assigns stable ids.
38
+ 4. **No markdown headings.** Plain text per slot item. Bullets render as `- `
39
+ automatically.
40
+ 5. **No filler.** "This PR makes changes" / "Tests should pass" / "Please
41
+ review carefully" — delete. Each slot must give the reviewer something
42
+ they couldn't see by reading the diff.
43
+
44
+ ## Output format
45
+
46
+ ```jsonc
47
+ {
48
+ "schema_version": "0.1.0",
49
+ "story_id": "<copy from fingerprint.story_id>",
50
+ "kind": "pr-body",
51
+ "generated_by": { "caller": "claude-code", "stage": "ai_synthesis" },
52
+ "narrative_slots": [
53
+ {
54
+ "id": "TP-NEW-1",
55
+ "slot": "summary",
56
+ "text": "premium ユーザーの cancelAtPeriodEnd 経路で userType=1 に降格していた回帰を src/lib/services/billing.ts で修正。INV-001 で宣言された不変条件と一致するように分岐を再整列した。",
57
+ "citations": {
58
+ "files": ["src/lib/services/billing.ts"],
59
+ "clause_ids": ["INV-001"]
60
+ },
61
+ "numerical_claims": []
62
+ },
63
+ {
64
+ "id": "TP-NEW-2",
65
+ "slot": "review_focus",
66
+ "text": "src/lib/services/billing.ts:142 の早期 return が、テスト対象外の status='premium_pending_cancel' 経路を引き起こす。テスト追加が無いまま invariant に依存しているので、reviewer は手で経路を辿るべき。",
67
+ "citations": {
68
+ "files": ["src/lib/services/billing.ts"]
69
+ }
70
+ },
71
+ {
72
+ "id": "TP-NEW-3",
73
+ "slot": "risks_synthesis",
74
+ "text": "drift items は spec_test 軸に偏っており、INV-001 を機械検証する test が無い。実装が変更されたら回帰検出が手作業になる。",
75
+ "citations": {
76
+ "clause_ids": ["INV-001"],
77
+ "drift_ids": ["DRIFT-AB12CD"]
78
+ },
79
+ "numerical_claims": [
80
+ { "field": "drift_high_count", "value": 1 }
81
+ ]
82
+ }
83
+ ]
84
+ }
85
+ ```
86
+
87
+ ## What VibePro does with your output
88
+
89
+ - JSON schema validation.
90
+ - Per-slot: every `citations.files[]` must exist in repo. Every
91
+ `finding_ids[]` must appear in `evidence.findings[]`. Every `clause_ids[]`
92
+ must appear in inferred spec. Every `drift_ids[]` must appear in drift.json.
93
+ Every `numerical_claims[]` must match fingerprint values.
94
+ - TP id stabilization via text similarity vs previous narrative.
95
+ - Writes `.vibepro/report/<story-id>/pr-body/narrative.json`.
96
+ - `vibepro pr prepare` splices your text into pr-body.md as 4 dedicated
97
+ sections at the top, attributed to TP ids.
98
+
99
+ ## Common mistakes
100
+
101
+ - **Generic summary.** "This PR addresses the story." Useless. Tell the
102
+ reviewer the *causal mechanism* of the change.
103
+ - **Inventing IDs.** If you write `DRIFT-XYZ123` but it isn't in
104
+ `fingerprint.drift.items[]`, you will be rejected. Always copy ids verbatim
105
+ from the fingerprint.
106
+ - **Numerical drift.** Writing "5 drift items" when there are 3 → rejection.
107
+ - **Filling open_questions with noise.** If you have no real open question,
108
+ return an empty array.
@@ -0,0 +1,95 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://vibepro.local/schemas/report-pr-body.json",
4
+ "title": "VibePro PR-body narrative",
5
+ "description": "AI-authored narrative slots inserted into pr-body.md. VibePro keeps the section skeleton fixed; AI only writes the prose inside the declared slots, citing only files / findings / clauses / drift items that exist.",
6
+ "type": "object",
7
+ "required": ["schema_version", "story_id", "kind", "narrative_slots"],
8
+ "properties": {
9
+ "schema_version": { "type": "string", "const": "0.1.0" },
10
+ "story_id": { "type": "string", "minLength": 1 },
11
+ "kind": { "type": "string", "const": "pr-body" },
12
+ "generated_at": { "type": "string" },
13
+ "generated_by": {
14
+ "type": "object",
15
+ "properties": {
16
+ "caller": { "type": "string" },
17
+ "stage": { "type": "string", "enum": ["ai_synthesis", "static_fallback"] }
18
+ }
19
+ },
20
+ "inputs_digest": { "type": "object" },
21
+ "previous_report_id": { "type": ["string", "null"] },
22
+ "narrative_slots": {
23
+ "type": "array",
24
+ "items": { "$ref": "#/$defs/slot" }
25
+ }
26
+ },
27
+ "$defs": {
28
+ "slot": {
29
+ "type": "object",
30
+ "required": ["id", "slot", "text"],
31
+ "properties": {
32
+ "id": {
33
+ "type": "string",
34
+ "description": "Talking-point id. Use TP-<n>. New slots may use TP-NEW-<n>; validator assigns stable ids."
35
+ },
36
+ "slot": {
37
+ "type": "string",
38
+ "enum": ["summary", "review_focus", "risks_synthesis", "open_questions"],
39
+ "description": "Where in pr-body.md the text is inserted. One id per slot per run; multiple ids per slot allowed for review_focus and open_questions only."
40
+ },
41
+ "text": {
42
+ "type": "string",
43
+ "minLength": 4,
44
+ "description": "Plain prose / single bullet. No markdown headings. <= 280 chars per slot item recommended."
45
+ },
46
+ "citations": {
47
+ "type": "object",
48
+ "description": "What the text refers to. Validator rejects any reference to a non-existent file / finding / clause / drift item.",
49
+ "properties": {
50
+ "files": {
51
+ "type": "array",
52
+ "items": { "type": "string" }
53
+ },
54
+ "finding_ids": {
55
+ "type": "array",
56
+ "items": { "type": "string" }
57
+ },
58
+ "clause_ids": {
59
+ "type": "array",
60
+ "items": { "type": "string" }
61
+ },
62
+ "drift_ids": {
63
+ "type": "array",
64
+ "items": { "type": "string" }
65
+ }
66
+ }
67
+ },
68
+ "numerical_claims": {
69
+ "type": "array",
70
+ "description": "Claims about counts that must match fingerprint values. Validator rejects on contradiction.",
71
+ "items": {
72
+ "type": "object",
73
+ "required": ["field", "value"],
74
+ "properties": {
75
+ "field": {
76
+ "type": "string",
77
+ "enum": [
78
+ "changed_files_count",
79
+ "drift_high_count",
80
+ "drift_total_count",
81
+ "requirement_invariant_count",
82
+ "requirement_contradiction_count",
83
+ "acceptance_criteria_count"
84
+ ]
85
+ },
86
+ "value": { "type": "integer", "minimum": 0 }
87
+ }
88
+ }
89
+ },
90
+ "first_seen_at": { "type": "string" },
91
+ "last_revised_at": { "type": "string" }
92
+ }
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,135 @@
1
+ import { mkdir, readFile, readdir, writeFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { similarity } from './spec-store.js';
5
+ import { WORKSPACE_DIR } from './workspace.js';
6
+
7
+ export const REPORT_SCHEMA_VERSION = '0.1.0';
8
+ export const REPORT_KINDS = new Set(['pr-body']);
9
+ const HISTORY_KEEP = 10;
10
+ const TP_SIMILARITY_THRESHOLD = 0.7;
11
+
12
+ export function getReportDir(repoRoot, storyId, kind) {
13
+ if (!storyId) throw new Error('storyId is required');
14
+ if (!REPORT_KINDS.has(kind)) throw new Error(`Unsupported report kind: ${kind}`);
15
+ return path.join(path.resolve(repoRoot), WORKSPACE_DIR, 'report', storyId, kind);
16
+ }
17
+
18
+ export function getNarrativeFile(repoRoot, storyId, kind) {
19
+ return path.join(getReportDir(repoRoot, storyId, kind), 'narrative.json');
20
+ }
21
+
22
+ export async function ensureReportDir(repoRoot, storyId, kind) {
23
+ const dir = getReportDir(repoRoot, storyId, kind);
24
+ await mkdir(path.join(dir, 'narrative.history'), { recursive: true });
25
+ return dir;
26
+ }
27
+
28
+ export async function readNarrative(repoRoot, storyId, kind) {
29
+ if (!storyId) return null;
30
+ try {
31
+ return JSON.parse(await readFile(getNarrativeFile(repoRoot, storyId, kind), 'utf8'));
32
+ } catch (error) {
33
+ if (error.code === 'ENOENT') return null;
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ export async function writeNarrative(repoRoot, storyId, kind, narrative) {
39
+ await ensureReportDir(repoRoot, storyId, kind);
40
+ const target = getNarrativeFile(repoRoot, storyId, kind);
41
+ await writeFile(target, `${JSON.stringify(narrative, null, 2)}\n`);
42
+
43
+ const historyDir = path.join(getReportDir(repoRoot, storyId, kind), 'narrative.history');
44
+ const stamp = (narrative.generated_at ?? new Date().toISOString()).replace(/[:.]/g, '-');
45
+ const historyPath = path.join(historyDir, `narrative-${stamp}.json`);
46
+ await writeFile(historyPath, `${JSON.stringify(narrative, null, 2)}\n`);
47
+ await pruneHistory(historyDir);
48
+ return target;
49
+ }
50
+
51
+ async function pruneHistory(historyDir) {
52
+ let entries;
53
+ try {
54
+ entries = await readdir(historyDir);
55
+ } catch (error) {
56
+ if (error.code === 'ENOENT') return;
57
+ throw error;
58
+ }
59
+ const sorted = entries
60
+ .filter((name) => name.startsWith('narrative-') && name.endsWith('.json'))
61
+ .sort()
62
+ .reverse();
63
+ const stale = sorted.slice(HISTORY_KEEP);
64
+ if (stale.length === 0) return;
65
+ await Promise.all(stale.map((name) => rm(path.join(historyDir, name), { force: true })));
66
+ }
67
+
68
+ export function stabilizeTalkingPointIds(narrative, previous) {
69
+ if (!narrative || !Array.isArray(narrative.narrative_slots)) {
70
+ return { ...narrative, narrative_slots: [] };
71
+ }
72
+ if (!previous || !Array.isArray(previous.narrative_slots)) {
73
+ return assignFreshTpIds(narrative);
74
+ }
75
+
76
+ const usedPrevious = new Set();
77
+ const usedNew = new Set();
78
+ const issuedAt = new Date().toISOString();
79
+
80
+ const slots = narrative.narrative_slots.map((slot) => {
81
+ let best = null;
82
+ for (const prev of previous.narrative_slots) {
83
+ if (usedPrevious.has(prev.id)) continue;
84
+ if (prev.slot !== slot.slot) continue;
85
+ const score = similarity(slot.text, prev.text);
86
+ if (!best || score > best.score) best = { slot: prev, score };
87
+ }
88
+ if (best && best.score >= TP_SIMILARITY_THRESHOLD) {
89
+ usedPrevious.add(best.slot.id);
90
+ usedNew.add(best.slot.id);
91
+ return {
92
+ ...slot,
93
+ id: best.slot.id,
94
+ first_seen_at: best.slot.first_seen_at ?? issuedAt,
95
+ last_revised_at: issuedAt
96
+ };
97
+ }
98
+ return {
99
+ ...slot,
100
+ id: nextFreshTpId(usedNew, previous, issuedAt),
101
+ first_seen_at: issuedAt,
102
+ last_revised_at: issuedAt
103
+ };
104
+ });
105
+
106
+ return { ...narrative, narrative_slots: slots };
107
+ }
108
+
109
+ function assignFreshTpIds(narrative) {
110
+ const usedNew = new Set();
111
+ const issuedAt = new Date().toISOString();
112
+ const slots = narrative.narrative_slots.map((slot) => ({
113
+ ...slot,
114
+ id: nextFreshTpId(usedNew, null, issuedAt),
115
+ first_seen_at: slot.first_seen_at ?? issuedAt,
116
+ last_revised_at: issuedAt
117
+ }));
118
+ return { ...narrative, narrative_slots: slots };
119
+ }
120
+
121
+ function nextFreshTpId(usedNew, previous, issuedAt) {
122
+ const reserved = new Set(usedNew);
123
+ if (previous && Array.isArray(previous.narrative_slots)) {
124
+ for (const slot of previous.narrative_slots) reserved.add(slot.id);
125
+ }
126
+ let n = 1;
127
+ while (reserved.has(`TP-${pad3(n)}`)) n += 1;
128
+ const id = `TP-${pad3(n)}`;
129
+ usedNew.add(id);
130
+ return id;
131
+ }
132
+
133
+ function pad3(n) {
134
+ return String(n).padStart(3, '0');
135
+ }
@@ -0,0 +1,192 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { readDrift, readInferredSpec } from './spec-store.js';
5
+
6
+ const SLOT_KINDS = new Set(['summary', 'review_focus', 'risks_synthesis', 'open_questions']);
7
+ const SLOT_LIMITS = {
8
+ summary: { min: 1, max: 1 },
9
+ review_focus: { min: 0, max: 5 },
10
+ risks_synthesis: { min: 1, max: 1 },
11
+ open_questions: { min: 0, max: 5 }
12
+ };
13
+ const NUMERICAL_FIELDS = new Set([
14
+ 'changed_files_count',
15
+ 'drift_high_count',
16
+ 'drift_total_count',
17
+ 'requirement_invariant_count',
18
+ 'requirement_contradiction_count',
19
+ 'acceptance_criteria_count'
20
+ ]);
21
+
22
+ export async function validateReportNarrative(repoRoot, narrative, fingerprint, options = {}) {
23
+ const errors = [];
24
+ const warnings = [];
25
+
26
+ if (!narrative || typeof narrative !== 'object') {
27
+ return { ok: false, errors: [{ code: 'invalid_root', message: 'narrative must be a JSON object' }], warnings };
28
+ }
29
+ if (narrative.schema_version !== '0.1.0') {
30
+ errors.push({ code: 'schema_version', message: `expected schema_version "0.1.0", got "${narrative.schema_version}"` });
31
+ }
32
+ if (narrative.kind !== 'pr-body') {
33
+ errors.push({ code: 'kind', message: `narrative.kind must be "pr-body"` });
34
+ }
35
+ if (!narrative.story_id || typeof narrative.story_id !== 'string') {
36
+ errors.push({ code: 'story_id', message: 'story_id is required' });
37
+ }
38
+ if (options.expectedStoryId && narrative.story_id !== options.expectedStoryId) {
39
+ errors.push({
40
+ code: 'story_id_mismatch',
41
+ message: `narrative.story_id "${narrative.story_id}" does not match expected "${options.expectedStoryId}"`
42
+ });
43
+ }
44
+ if (!Array.isArray(narrative.narrative_slots)) {
45
+ errors.push({ code: 'slots_missing', message: 'narrative_slots must be an array' });
46
+ return { ok: false, errors, warnings, narrative };
47
+ }
48
+
49
+ const slotCounts = countSlots(narrative.narrative_slots);
50
+ for (const [slot, { min, max }] of Object.entries(SLOT_LIMITS)) {
51
+ const count = slotCounts.get(slot) ?? 0;
52
+ if (count < min) {
53
+ errors.push({ code: 'slot_min', message: `slot "${slot}" requires at least ${min} entry, got ${count}` });
54
+ }
55
+ if (count > max) {
56
+ errors.push({ code: 'slot_max', message: `slot "${slot}" allows at most ${max} entries, got ${count}` });
57
+ }
58
+ }
59
+
60
+ const driftItems = fingerprint?.drift?.items ?? [];
61
+ const driftIds = new Set(driftItems.map((item) => item.id));
62
+ const findingIds = new Set((fingerprint?.findings ?? []).map((entry) => entry.id));
63
+ const clauseIds = new Set((fingerprint?.inferred_spec?.clauses ?? []).map((entry) => entry.id));
64
+ const numericalTruth = fingerprint?.numerical_truth ?? {};
65
+
66
+ for (let index = 0; index < narrative.narrative_slots.length; index += 1) {
67
+ const slot = narrative.narrative_slots[index];
68
+ const slotErrors = await validateSlot(repoRoot, slot, index, {
69
+ driftIds, findingIds, clauseIds, numericalTruth
70
+ });
71
+ errors.push(...slotErrors);
72
+ }
73
+
74
+ return {
75
+ ok: errors.length === 0,
76
+ errors,
77
+ warnings,
78
+ narrative
79
+ };
80
+ }
81
+
82
+ function countSlots(slots) {
83
+ const counts = new Map();
84
+ for (const slot of slots) {
85
+ if (!slot || typeof slot !== 'object') continue;
86
+ counts.set(slot.slot, (counts.get(slot.slot) ?? 0) + 1);
87
+ }
88
+ return counts;
89
+ }
90
+
91
+ async function validateSlot(repoRoot, slot, index, ctx) {
92
+ const errors = [];
93
+ const locator = slot?.id ? `narrative_slots[${index}] (${slot.id})` : `narrative_slots[${index}]`;
94
+ if (!slot || typeof slot !== 'object') {
95
+ errors.push({ code: 'slot_shape', message: `${locator} must be an object` });
96
+ return errors;
97
+ }
98
+ if (!slot.id || typeof slot.id !== 'string') {
99
+ errors.push({ code: 'slot_id', message: `${locator}.id required` });
100
+ }
101
+ if (!SLOT_KINDS.has(slot.slot)) {
102
+ errors.push({
103
+ code: 'slot_kind',
104
+ message: `${locator}.slot must be one of ${[...SLOT_KINDS].join('|')}`
105
+ });
106
+ }
107
+ if (typeof slot.text !== 'string' || slot.text.trim().length < 4) {
108
+ errors.push({ code: 'slot_text', message: `${locator}.text must be a non-empty string` });
109
+ }
110
+
111
+ const citations = slot.citations ?? {};
112
+ for (const file of citations.files ?? []) {
113
+ const fileResult = await fileExists(repoRoot, file);
114
+ if (!fileResult) {
115
+ errors.push({
116
+ code: 'citation_file_missing',
117
+ message: `${locator}.citations.files: "${file}" not found in repository`
118
+ });
119
+ }
120
+ }
121
+ for (const id of citations.finding_ids ?? []) {
122
+ if (!ctx.findingIds.has(id)) {
123
+ errors.push({
124
+ code: 'citation_finding_missing',
125
+ message: `${locator}.citations.finding_ids: "${id}" not found in evidence.findings`
126
+ });
127
+ }
128
+ }
129
+ for (const id of citations.clause_ids ?? []) {
130
+ if (!ctx.clauseIds.has(id)) {
131
+ errors.push({
132
+ code: 'citation_clause_missing',
133
+ message: `${locator}.citations.clause_ids: "${id}" not found in inferred spec clauses`
134
+ });
135
+ }
136
+ }
137
+ for (const id of citations.drift_ids ?? []) {
138
+ if (!ctx.driftIds.has(id)) {
139
+ errors.push({
140
+ code: 'citation_drift_missing',
141
+ message: `${locator}.citations.drift_ids: "${id}" not found in drift.items`
142
+ });
143
+ }
144
+ }
145
+
146
+ for (const claim of slot.numerical_claims ?? []) {
147
+ if (!claim || !NUMERICAL_FIELDS.has(claim.field)) {
148
+ errors.push({
149
+ code: 'numerical_field',
150
+ message: `${locator}.numerical_claims: field must be one of ${[...NUMERICAL_FIELDS].join('|')}`
151
+ });
152
+ continue;
153
+ }
154
+ const truth = ctx.numericalTruth[claim.field];
155
+ if (truth === undefined) {
156
+ errors.push({
157
+ code: 'numerical_truth_missing',
158
+ message: `${locator}.numerical_claims: fingerprint lacks "${claim.field}" — cannot verify`
159
+ });
160
+ continue;
161
+ }
162
+ if (typeof claim.value !== 'number' || claim.value !== truth) {
163
+ errors.push({
164
+ code: 'numerical_contradiction',
165
+ message: `${locator}.numerical_claims: ${claim.field}=${claim.value} contradicts fingerprint=${truth}`
166
+ });
167
+ }
168
+ }
169
+
170
+ return errors;
171
+ }
172
+
173
+ async function fileExists(repoRoot, relativeFile) {
174
+ try {
175
+ const stats = await stat(path.join(repoRoot, relativeFile));
176
+ return stats.isFile();
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ // Cross-check that fingerprint slot existence matches expectation when AI is rerun
183
+ // without changes. Used in tests / drift detection later.
184
+ export async function reconcileWithRunArtifacts(repoRoot, narrative, storyId) {
185
+ const drift = await readDrift(repoRoot, storyId);
186
+ const spec = await readInferredSpec(repoRoot, storyId);
187
+ return {
188
+ drift_present: Boolean(drift),
189
+ spec_present: Boolean(spec),
190
+ narrative_slots: narrative?.narrative_slots?.length ?? 0
191
+ };
192
+ }