buildanything 2.0.0 → 2.1.2

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 (115) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +9 -1
  3. package/README.md +57 -61
  4. package/agents/a11y-architect.md +2 -0
  5. package/agents/briefing-officer.md +172 -0
  6. package/agents/business-model.md +14 -12
  7. package/agents/code-architect.md +6 -1
  8. package/agents/code-reviewer.md +3 -2
  9. package/agents/code-simplifier.md +12 -4
  10. package/agents/design-brand-guardian.md +19 -0
  11. package/agents/design-critic.md +16 -11
  12. package/agents/design-inclusive-visuals-specialist.md +2 -0
  13. package/agents/design-ui-designer.md +17 -0
  14. package/agents/design-ux-architect.md +15 -0
  15. package/agents/design-ux-researcher.md +102 -7
  16. package/agents/engineering-ai-engineer.md +2 -0
  17. package/agents/engineering-backend-architect.md +2 -0
  18. package/agents/engineering-data-engineer.md +2 -0
  19. package/agents/engineering-devops-automator.md +2 -0
  20. package/agents/engineering-frontend-developer.md +13 -0
  21. package/agents/engineering-mobile-app-builder.md +2 -0
  22. package/agents/engineering-rapid-prototyper.md +15 -2
  23. package/agents/engineering-security-engineer.md +2 -0
  24. package/agents/engineering-senior-developer.md +13 -0
  25. package/agents/engineering-sre.md +2 -0
  26. package/agents/engineering-technical-writer.md +2 -0
  27. package/agents/feature-intel.md +8 -7
  28. package/agents/ios-app-review-guardian.md +2 -0
  29. package/agents/ios-foundation-models-specialist.md +2 -0
  30. package/agents/ios-product-reality-auditor.md +292 -0
  31. package/agents/ios-storekit-specialist.md +2 -0
  32. package/agents/ios-swift-architect.md +1 -0
  33. package/agents/ios-swift-search.md +1 -0
  34. package/agents/ios-swift-ui-design.md +7 -4
  35. package/agents/marketing-app-store-optimizer.md +2 -0
  36. package/agents/planner.md +6 -1
  37. package/agents/pr-test-analyzer.md +3 -2
  38. package/agents/product-feedback-synthesizer.md +62 -0
  39. package/agents/product-owner.md +163 -0
  40. package/agents/product-reality-auditor.md +216 -0
  41. package/agents/product-spec-writer.md +176 -0
  42. package/agents/refactor-cleaner.md +9 -1
  43. package/agents/security-reviewer.md +2 -1
  44. package/agents/silent-failure-hunter.md +2 -1
  45. package/agents/swift-build-resolver.md +2 -0
  46. package/agents/swift-reviewer.md +2 -1
  47. package/agents/tech-feasibility.md +5 -3
  48. package/agents/testing-api-tester.md +2 -0
  49. package/agents/testing-evidence-collector.md +24 -0
  50. package/agents/testing-performance-benchmarker.md +2 -0
  51. package/agents/testing-reality-checker.md +2 -1
  52. package/agents/visual-research.md +7 -5
  53. package/bin/adapters/scribe-tool.ts +4 -2
  54. package/bin/adapters/write-lease-tool.ts +1 -1
  55. package/bin/buildanything-runtime.ts +20 -107
  56. package/bin/graph-index.js +24 -0
  57. package/bin/graph-index.ts +340 -0
  58. package/bin/mcp-servers/graph-mcp.js +26 -0
  59. package/bin/mcp-servers/graph-mcp.ts +481 -0
  60. package/bin/mcp-servers/orchestrator-mcp.js +26 -0
  61. package/bin/mcp-servers/orchestrator-mcp.ts +361 -0
  62. package/bin/setup.js +272 -111
  63. package/commands/build.md +424 -177
  64. package/commands/idea-sweep.md +2 -2
  65. package/commands/setup.md +15 -4
  66. package/commands/ux-review.md +3 -3
  67. package/commands/verify.md +3 -0
  68. package/docs/migration/phase-graph.yaml +573 -157
  69. package/hooks/design-md-lint +4 -0
  70. package/hooks/design-md-lint.ts +295 -0
  71. package/hooks/pre-tool-use.ts +37 -6
  72. package/hooks/record-mode-transitions.ts +63 -6
  73. package/hooks/subagent-start.ts +3 -2
  74. package/package.json +3 -1
  75. package/protocols/agent-prompt-authoring.md +165 -0
  76. package/protocols/architecture-schema.md +10 -3
  77. package/protocols/cleanup.md +4 -0
  78. package/protocols/decision-log.md +8 -4
  79. package/protocols/design-md-authoring.md +520 -0
  80. package/protocols/design-md-spec.md +362 -0
  81. package/protocols/fake-data-detector.md +1 -1
  82. package/protocols/ios-fake-data-detector.md +65 -0
  83. package/protocols/ios-phase-branches.md +128 -43
  84. package/protocols/launch-readiness.md +9 -5
  85. package/protocols/metric-loop.md +1 -1
  86. package/protocols/page-spec-schema.md +234 -0
  87. package/protocols/product-spec-schema.md +354 -0
  88. package/protocols/sprint-tasks-schema.md +53 -0
  89. package/protocols/state-schema.json +38 -3
  90. package/protocols/state-schema.md +32 -2
  91. package/protocols/verify.md +29 -1
  92. package/protocols/web-phase-branches.md +246 -76
  93. package/skills/ios/ios-bootstrap/SKILL.md +1 -1
  94. package/src/graph/ids.ts +86 -0
  95. package/src/graph/index.ts +32 -0
  96. package/src/graph/parser/architecture.ts +603 -0
  97. package/src/graph/parser/component-manifest.ts +268 -0
  98. package/src/graph/parser/decisions-jsonl.ts +407 -0
  99. package/src/graph/parser/design-md-pass2.ts +253 -0
  100. package/src/graph/parser/design-md.ts +477 -0
  101. package/src/graph/parser/page-spec.ts +496 -0
  102. package/src/graph/parser/product-spec.ts +930 -0
  103. package/src/graph/parser/screenshot.ts +342 -0
  104. package/src/graph/parser/sprint-tasks.ts +317 -0
  105. package/src/graph/storage/index.ts +1154 -0
  106. package/src/graph/types.ts +432 -0
  107. package/src/graph/util/dhash.ts +84 -0
  108. package/src/lrr/aggregator.ts +105 -10
  109. package/src/orchestrator/hooks/context-header.ts +34 -10
  110. package/src/orchestrator/hooks/token-accounting.ts +25 -14
  111. package/src/orchestrator/mcp/cycle-counter.ts +2 -1
  112. package/src/orchestrator/mcp/scribe.ts +27 -16
  113. package/src/orchestrator/mcp/write-lease.ts +30 -13
  114. package/src/orchestrator/phase4-shared-context.ts +20 -4
  115. package/protocols/visual-dna.md +0 -185
@@ -0,0 +1,1154 @@
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ renameSync,
5
+ unlinkSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ openSync,
9
+ fsyncSync,
10
+ closeSync,
11
+ readdirSync,
12
+ } from "node:fs";
13
+ import { join, resolve, dirname } from "node:path";
14
+
15
+ import type {
16
+ GraphFragment,
17
+ GraphNode,
18
+ GraphEdge,
19
+ FeatureNode,
20
+ ScreenNode,
21
+ StateNode,
22
+ TransitionNode,
23
+ BusinessRuleNode,
24
+ FailureModeNode,
25
+ AcceptanceCriterionNode,
26
+ PersonaConstraintNode,
27
+ DesignDocRootNode,
28
+ DnaAxisNode,
29
+ BrandDnaGuidelineNode,
30
+ BrandReferenceNode,
31
+ ComponentManifestEntryNode,
32
+ TokenNode,
33
+ PageSpecNode,
34
+ WireframeSectionNode,
35
+ ScreenStateSlotNode,
36
+ ScreenComponentUseNode,
37
+ KeyCopyNode,
38
+ ApiContractNode,
39
+ TaskNode,
40
+ DecisionNode,
41
+ ScreenshotNode,
42
+ ImageComponentDetectionNode,
43
+ DogfoodFindingNode,
44
+ BrandDriftObservationNode,
45
+ } from "../types.js";
46
+ import { hammingDistance } from "../util/dhash.js";
47
+ import { kebab } from "../ids.js";
48
+
49
+ // ── Cross-slice edge validation ─────────────────────────────────────────
50
+
51
+ export function validateCrossSliceEdges(graph: GraphFragment): string[] {
52
+ const nodeIds = new Set(graph.nodes.map((n) => n.id));
53
+ const warnings: string[] = [];
54
+ for (const e of graph.edges) {
55
+ if (!nodeIds.has(e.source)) {
56
+ warnings.push(`Dangling edge: ${e.relation} from ${e.source} to ${e.target} — source node not found`);
57
+ }
58
+ if (!nodeIds.has(e.target)) {
59
+ warnings.push(`Dangling edge: ${e.relation} from ${e.source} to ${e.target} — target node not found`);
60
+ }
61
+ }
62
+ return warnings;
63
+ }
64
+
65
+ // ── Path ────────────────────────────────────────────────────────────────
66
+
67
+ export function graphPath(projectDir: string): string {
68
+ return join(resolve(projectDir), ".buildanything", "graph", "slice-1.json");
69
+ }
70
+
71
+ // ── Schema validation ───────────────────────────────────────────────────
72
+
73
+ const SUPPORTED_SCHEMAS: ReadonlySet<string> = new Set([
74
+ "buildanything-slice-1",
75
+ "buildanything-slice-2",
76
+ "buildanything-slice-3",
77
+ "buildanything-slice-4",
78
+ "buildanything-slice-5",
79
+ ]);
80
+
81
+ function isSupportedSchema(s: unknown): boolean {
82
+ return typeof s === "string" && SUPPORTED_SCHEMAS.has(s);
83
+ }
84
+
85
+ // ── Load ────────────────────────────────────────────────────────────────
86
+
87
+ export function loadGraph(projectDir: string): GraphFragment | null {
88
+ const p = graphPath(projectDir);
89
+ if (!existsSync(p)) return null;
90
+
91
+ let raw: string;
92
+ try {
93
+ raw = readFileSync(p, "utf-8");
94
+ } catch (err) {
95
+ console.error(`graph/storage: failed to read ${p}: ${err}`);
96
+ return null;
97
+ }
98
+
99
+ let parsed: unknown;
100
+ try {
101
+ parsed = JSON.parse(raw);
102
+ } catch (err) {
103
+ console.error(`graph/storage: invalid JSON in ${p}: ${err}`);
104
+ return null;
105
+ }
106
+
107
+ const obj = parsed as Record<string, unknown>;
108
+ // schema/version guard for forward compat
109
+ if (obj.version !== 1 || !isSupportedSchema(obj.schema)) {
110
+ console.error(`graph/storage: unsupported version/schema in ${p}`);
111
+ return null;
112
+ }
113
+
114
+ return parsed as GraphFragment;
115
+ }
116
+
117
+ // ── Save (atomic write) ────────────────────────────────────────────────
118
+
119
+ export function saveGraph(projectDir: string, fragment: GraphFragment, targetFile: string = "slice-1.json"): void {
120
+ const dir = join(resolve(projectDir), ".buildanything", "graph");
121
+ const target = join(dir, targetFile);
122
+ const tmp = `${target}.tmp`;
123
+
124
+ mkdirSync(dirname(target), { recursive: true });
125
+
126
+ const content = JSON.stringify(fragment, null, 2) + "\n";
127
+
128
+ try {
129
+ writeFileSync(tmp, content, "utf-8");
130
+
131
+ // fsync survives power loss
132
+ const fd = openSync(tmp, "r+");
133
+ try {
134
+ fsyncSync(fd);
135
+ } finally {
136
+ closeSync(fd);
137
+ }
138
+
139
+ renameSync(tmp, target);
140
+ } catch (err) {
141
+ try {
142
+ if (existsSync(tmp)) unlinkSync(tmp);
143
+ } catch { /* best effort */ }
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ // ── queryFeatureList ─────────────────────────────────────────────────────
149
+
150
+ export interface FeatureListEntry {
151
+ id: string;
152
+ label: string;
153
+ kebab_anchor: string;
154
+ }
155
+
156
+ export function queryFeatureList(graph: GraphFragment): FeatureListEntry[] {
157
+ return graph.nodes
158
+ .filter((n): n is FeatureNode => n.entity_type === 'feature')
159
+ .map((n) => ({ id: n.id, label: n.label, kebab_anchor: n.kebab_anchor }))
160
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
161
+ }
162
+
163
+ // ── Query types ─────────────────────────────────────────────────────────
164
+
165
+ export interface FeatureQueryResult {
166
+ feature: { id: string; label: string; kebab_anchor: string };
167
+ screens: { id: string; label: string; description: string }[];
168
+ states: { id: string; label: string; is_initial: boolean; meta_state: boolean }[];
169
+ transitions: {
170
+ from: string; to: string; trigger: string;
171
+ preconditions: string; side_effects: string;
172
+ }[];
173
+ business_rules: { id: string; text: string; value: string | null; decision_needed: boolean }[];
174
+ failure_modes: {
175
+ trigger: string; user_sees: string; user_can: string; system_does: string;
176
+ }[];
177
+ persona_constraints: {
178
+ constraint_text: string; applies_to_persona: string; cited_source: string;
179
+ }[];
180
+ acceptance_criteria: { id: string; text: string; verified: boolean }[];
181
+ depends_on: string[];
182
+ }
183
+
184
+ export interface ScreenQueryResult {
185
+ screen: { id: string; label: string; description: string };
186
+ owning_features: string[];
187
+ states_visible_here: { id: string; label: string }[];
188
+ }
189
+
190
+ export interface AcceptanceQueryResult {
191
+ acceptance_criteria: { id: string; text: string; verified: boolean }[];
192
+ business_rules_in_scope: { id: string; text: string; value: string | null; decision_needed: boolean }[];
193
+ persona_constraints: {
194
+ constraint_text: string; applies_to_persona: string; cited_source: string;
195
+ }[];
196
+ }
197
+
198
+ // ── Helpers ─────────────────────────────────────────────────────────────
199
+
200
+ function byId(a: { id: string }, b: { id: string }): number {
201
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
202
+ }
203
+
204
+ function nodesOfType<T extends GraphNode>(nodes: GraphNode[], t: T["entity_type"]): T[] {
205
+ return nodes.filter((n): n is T => n.entity_type === t);
206
+ }
207
+
208
+ // ── queryFeature ────────────────────────────────────────────────────────
209
+
210
+ export function queryFeature(graph: GraphFragment, feature_id: string): FeatureQueryResult | null {
211
+ const feature = graph.nodes.find(
212
+ (n): n is FeatureNode => n.entity_type === "feature" && n.id === feature_id,
213
+ );
214
+ if (!feature) return null;
215
+
216
+ const stateNodes = nodesOfType<StateNode>(graph.nodes, "state")
217
+ .filter((s) => s.feature_id === feature_id);
218
+ const stateIds = new Set(stateNodes.map((s) => s.id));
219
+
220
+ const screens = nodesOfType<ScreenNode>(graph.nodes, "screen")
221
+ .filter((s) => s.feature_ids.includes(feature_id))
222
+ .map((s) => ({ id: s.id, label: s.label, description: s.description }))
223
+ .sort(byId);
224
+
225
+ const states = stateNodes
226
+ .map((s) => ({ id: s.id, label: s.label, is_initial: s.is_initial, meta_state: s.meta_state }))
227
+ .sort(byId);
228
+
229
+ const transitions = nodesOfType<TransitionNode>(graph.nodes, "transition")
230
+ .filter((t) => stateIds.has(t.from_state_id))
231
+ .sort(byId)
232
+ .map((t) => ({
233
+ from: t.from_state_id, to: t.to_state_id, trigger: t.trigger,
234
+ preconditions: t.preconditions, side_effects: t.side_effects,
235
+ }));
236
+
237
+ const business_rules = nodesOfType<BusinessRuleNode>(graph.nodes, "business_rule")
238
+ .filter((r) => r.feature_id === feature_id)
239
+ .sort(byId)
240
+ .map((r) => ({ id: r.id, text: r.text, value: r.value, decision_needed: r.decision_needed }));
241
+
242
+ const failure_modes = nodesOfType<FailureModeNode>(graph.nodes, "failure_mode")
243
+ .filter((f) => f.feature_id === feature_id)
244
+ .sort(byId)
245
+ .map((f) => ({ trigger: f.trigger, user_sees: f.user_sees, user_can: f.user_can, system_does: f.system_does }));
246
+
247
+ const persona_constraints = nodesOfType<PersonaConstraintNode>(graph.nodes, "persona_constraint")
248
+ .filter((c) => c.feature_id === feature_id)
249
+ .sort(byId)
250
+ .map((c) => ({ constraint_text: c.constraint_text, applies_to_persona: c.persona_id, cited_source: c.cited_source }));
251
+
252
+ const acceptance_criteria = nodesOfType<AcceptanceCriterionNode>(graph.nodes, "acceptance_criterion")
253
+ .filter((a) => a.feature_id === feature_id)
254
+ .sort(byId)
255
+ .map((a) => ({ id: a.id, text: a.text, verified: a.verified }));
256
+
257
+ const depends_on = graph.edges
258
+ .filter((e) => e.relation === "depends_on" && e.source === feature_id)
259
+ .map((e) => e.target)
260
+ .sort();
261
+
262
+ return {
263
+ feature: { id: feature.id, label: feature.label, kebab_anchor: feature.kebab_anchor },
264
+ screens, states, transitions, business_rules, failure_modes,
265
+ persona_constraints, acceptance_criteria, depends_on,
266
+ };
267
+ }
268
+
269
+ // ── queryScreen ─────────────────────────────────────────────────────────
270
+
271
+ export function queryScreen(graph: GraphFragment, screen_id: string): ScreenQueryResult | null;
272
+ export function queryScreen(graph: GraphFragment, screen_id: string, opts: { full: true }): ScreenFullQueryResult | null;
273
+ export function queryScreen(graph: GraphFragment, screen_id: string, opts?: { full?: boolean }): ScreenQueryResult | ScreenFullQueryResult | null;
274
+ export function queryScreen(graph: GraphFragment, screen_id: string, opts?: { full?: boolean }): ScreenQueryResult | ScreenFullQueryResult | null {
275
+ if (opts?.full) return queryScreenFull(graph, screen_id);
276
+
277
+ const screen = graph.nodes.find(
278
+ (n): n is ScreenNode => n.entity_type === "screen" && n.id === screen_id,
279
+ );
280
+ if (!screen) return null;
281
+
282
+ const owning_features = [...screen.feature_ids].sort();
283
+
284
+ const allStates = nodesOfType<StateNode>(graph.nodes, "state");
285
+ const seen = new Set<string>();
286
+ const states_visible_here: { id: string; label: string }[] = [];
287
+ for (const fid of owning_features) {
288
+ for (const s of allStates) {
289
+ if (s.feature_id === fid && !seen.has(s.id)) {
290
+ seen.add(s.id);
291
+ states_visible_here.push({ id: s.id, label: s.label });
292
+ }
293
+ }
294
+ }
295
+ states_visible_here.sort(byId);
296
+
297
+ return {
298
+ screen: { id: screen.id, label: screen.label, description: screen.description },
299
+ owning_features,
300
+ states_visible_here,
301
+ };
302
+ }
303
+
304
+ // ── queryAcceptance ─────────────────────────────────────────────────────
305
+
306
+ export function queryAcceptance(graph: GraphFragment, feature_id: string): AcceptanceQueryResult | null {
307
+ const feature = graph.nodes.find(
308
+ (n) => n.entity_type === "feature" && n.id === feature_id,
309
+ );
310
+ if (!feature) return null;
311
+
312
+ const acceptance_criteria = nodesOfType<AcceptanceCriterionNode>(graph.nodes, "acceptance_criterion")
313
+ .filter((a) => a.feature_id === feature_id)
314
+ .sort(byId)
315
+ .map((a) => ({ id: a.id, text: a.text, verified: a.verified }));
316
+
317
+ const business_rules_in_scope = nodesOfType<BusinessRuleNode>(graph.nodes, "business_rule")
318
+ .filter((r) => r.feature_id === feature_id)
319
+ .sort(byId)
320
+ .map((r) => ({ id: r.id, text: r.text, value: r.value, decision_needed: r.decision_needed }));
321
+
322
+ const persona_constraints = nodesOfType<PersonaConstraintNode>(graph.nodes, "persona_constraint")
323
+ .filter((c) => c.feature_id === feature_id)
324
+ .sort(byId)
325
+ .map((c) => ({ constraint_text: c.constraint_text, applies_to_persona: c.persona_id, cited_source: c.cited_source }));
326
+
327
+ return { acceptance_criteria, business_rules_in_scope, persona_constraints };
328
+ }
329
+
330
+ // ── DnaQueryResult ──────────────────────────────────────────────────────
331
+
332
+ const DNA_AXIS_ORDER = ["scope", "density", "character", "material", "motion", "type", "copy"] as const;
333
+
334
+ export interface DnaQueryResult {
335
+ design_doc: {
336
+ id: string;
337
+ name: string;
338
+ description: string;
339
+ locked_at: string;
340
+ pass_complete: { pass1: boolean; pass2: boolean };
341
+ };
342
+ axes: Array<{
343
+ name: "scope" | "density" | "character" | "material" | "motion" | "type" | "copy";
344
+ value: string;
345
+ rationale: string;
346
+ }>;
347
+ guidelines: {
348
+ dos: Array<{ id: string; text: string; axis_scope: string | null }>;
349
+ donts: Array<{ id: string; text: string; axis_scope: string | null }>;
350
+ };
351
+ references: Array<{
352
+ id: string;
353
+ url_or_path: string;
354
+ exemplifies_axes: string[];
355
+ }>;
356
+ lint_status: "pass" | "warn" | "fail" | null;
357
+ }
358
+
359
+ // ── queryDna ────────────────────────────────────────────────────────────
360
+
361
+ export function queryDna(graph: GraphFragment, projectDir: string = process.cwd()): DnaQueryResult | null {
362
+ const root = graph.nodes.find(
363
+ (n): n is DesignDocRootNode => n.entity_type === "design_doc_root",
364
+ );
365
+ if (!root) return null;
366
+
367
+ const axisOrder = new Map(DNA_AXIS_ORDER.map((a, i) => [a, i]));
368
+ const axes = nodesOfType<DnaAxisNode>(graph.nodes, "dna_axis")
369
+ .sort((a, b) => (axisOrder.get(a.axis_name) ?? 99) - (axisOrder.get(b.axis_name) ?? 99))
370
+ .map((a) => ({ name: a.axis_name, value: a.value, rationale: a.rationale }));
371
+
372
+ const allGuidelines = nodesOfType<BrandDnaGuidelineNode>(graph.nodes, "brand_dna_guideline");
373
+ const dos = allGuidelines
374
+ .filter((g) => g.polarity === "do")
375
+ .sort(byId)
376
+ .map((g) => ({ id: g.id, text: g.text, axis_scope: g.axis_scope }));
377
+ const donts = allGuidelines
378
+ .filter((g) => g.polarity === "dont")
379
+ .sort(byId)
380
+ .map((g) => ({ id: g.id, text: g.text, axis_scope: g.axis_scope }));
381
+
382
+ const references = nodesOfType<BrandReferenceNode>(graph.nodes, "brand_reference")
383
+ .sort(byId)
384
+ .map((r) => ({ id: r.id, url_or_path: r.url_or_path, exemplifies_axes: r.exemplifies_axes }));
385
+
386
+ let lint_status: "pass" | "warn" | "fail" | null = null;
387
+ const lintPath = join(resolve(projectDir), ".buildanything", "graph", "lint-status.json");
388
+ if (existsSync(lintPath)) {
389
+ try {
390
+ const parsed = JSON.parse(readFileSync(lintPath, "utf-8")) as Record<string, unknown>;
391
+ const s = parsed.status;
392
+ if (s === "pass" || s === "warn" || s === "fail") {
393
+ lint_status = s;
394
+ } else {
395
+ console.error(`graph/storage: invalid lint_status value in ${lintPath}`);
396
+ }
397
+ } catch {
398
+ console.error(`graph/storage: failed to parse ${lintPath}`);
399
+ }
400
+ }
401
+
402
+ return {
403
+ design_doc: {
404
+ id: root.id,
405
+ name: root.name,
406
+ description: root.description,
407
+ locked_at: root.locked_at,
408
+ pass_complete: root.pass_complete,
409
+ },
410
+ axes,
411
+ guidelines: { dos, donts },
412
+ references,
413
+ lint_status,
414
+ };
415
+ }
416
+
417
+ // ── ManifestQueryResult ─────────────────────────────────────────────────
418
+
419
+ export interface ManifestEntryView {
420
+ slot: string;
421
+ library: string;
422
+ variant: string;
423
+ source_ref: string | null;
424
+ hard_gate: boolean;
425
+ fallback_plan?: string;
426
+ }
427
+
428
+ export interface ManifestQueryResult {
429
+ entries: ManifestEntryView[];
430
+ by_slot: Record<string, ManifestEntryView>;
431
+ }
432
+
433
+ // ── queryManifest ───────────────────────────────────────────────────────
434
+
435
+ function toManifestEntry(node: ComponentManifestEntryNode): ManifestEntryView {
436
+ const entry: ManifestEntryView = {
437
+ slot: node.slot,
438
+ library: node.library,
439
+ variant: node.variant,
440
+ source_ref: node.source_ref,
441
+ hard_gate: node.hard_gate,
442
+ };
443
+ if (node.fallback_plan !== undefined) entry.fallback_plan = node.fallback_plan;
444
+ return entry;
445
+ }
446
+
447
+ export function queryManifest(graph: GraphFragment, slot?: string): ManifestQueryResult | null {
448
+ const manifestNodes = nodesOfType<ComponentManifestEntryNode>(graph.nodes, "component_manifest_entry");
449
+ if (manifestNodes.length === 0) return null;
450
+
451
+ const entries = manifestNodes
452
+ .map(toManifestEntry)
453
+ .sort((a, b) => (a.slot < b.slot ? -1 : a.slot > b.slot ? 1 : 0));
454
+
455
+ const by_slot: Record<string, ManifestEntryView> = {};
456
+ for (const e of entries) by_slot[e.slot] = e;
457
+
458
+ if (slot !== undefined) {
459
+ const match = by_slot[slot];
460
+ return {
461
+ entries: match ? [match] : [],
462
+ by_slot: match ? { [slot]: match } : {},
463
+ };
464
+ }
465
+
466
+ return { entries, by_slot };
467
+ }
468
+
469
+ // ── loadAllGraphs ───────────────────────────────────────────────────────
470
+
471
+ export function loadAllGraphs(projectDir: string): GraphFragment | null {
472
+ const dir = join(resolve(projectDir), ".buildanything", "graph");
473
+ if (!existsSync(dir)) return null;
474
+
475
+ const files = readdirSync(dir)
476
+ .filter((f) => f.endsWith(".json") && f !== "lint-status.json")
477
+ .sort();
478
+
479
+ const fragments: GraphFragment[] = [];
480
+ for (const file of files) {
481
+ const p = join(dir, file);
482
+ try {
483
+ const parsed = JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
484
+ if (
485
+ parsed.version !== 1 ||
486
+ !isSupportedSchema(parsed.schema)
487
+ ) {
488
+ console.error(`graph/storage: unsupported version/schema in ${p}`);
489
+ continue;
490
+ }
491
+ fragments.push(parsed as unknown as GraphFragment);
492
+ } catch (err) {
493
+ console.error(`graph/storage: failed to parse ${p}: ${err}`);
494
+ }
495
+ }
496
+
497
+ if (fragments.length === 0) return null;
498
+
499
+ const nodeMap = new Map<string, GraphNode>();
500
+ const nodeSource = new Map<string, string>();
501
+ for (const frag of fragments) {
502
+ for (const node of frag.nodes) {
503
+ if (nodeMap.has(node.id)) {
504
+ const prevSource = nodeSource.get(node.id) ?? "<unknown>";
505
+ console.error(`graph/storage: duplicate node id "${node.id}" across fragments ${prevSource} and ${frag.source_file}; last wins`);
506
+ }
507
+ nodeMap.set(node.id, node);
508
+ nodeSource.set(node.id, frag.source_file);
509
+ }
510
+ }
511
+
512
+ const edges: GraphEdge[] = [];
513
+ for (const frag of fragments) edges.push(...frag.edges);
514
+
515
+ const hasSlice5 = fragments.some((f) => f.schema === "buildanything-slice-5");
516
+ const hasSlice4 = fragments.some((f) => f.schema === "buildanything-slice-4");
517
+ const hasSlice3 = fragments.some((f) => f.schema === "buildanything-slice-3");
518
+ const hasSlice2 = fragments.some((f) => f.schema === "buildanything-slice-2");
519
+ const latestProducedAt = fragments.reduce(
520
+ (max, f) => (f.produced_at > max ? f.produced_at : max),
521
+ fragments[0].produced_at,
522
+ );
523
+
524
+ return {
525
+ version: 1,
526
+ schema: hasSlice5 ? "buildanything-slice-5" : hasSlice4 ? "buildanything-slice-4" : hasSlice3 ? "buildanything-slice-3" : hasSlice2 ? "buildanything-slice-2" : "buildanything-slice-1",
527
+ source_file: "<merged>",
528
+ source_sha: "0".repeat(64),
529
+ produced_at: latestProducedAt,
530
+ nodes: [...nodeMap.values()],
531
+ edges,
532
+ };
533
+ }
534
+
535
+ // ── TokenQueryResult ────────────────────────────────────────────────────
536
+
537
+ export interface TokenQueryResult {
538
+ token: {
539
+ id: string;
540
+ name: string;
541
+ value: string;
542
+ layer: TokenNode["layer"];
543
+ axis_provenance: TokenNode["axis_provenance"];
544
+ category: string | null;
545
+ };
546
+ derived_from_axis_id: string | null;
547
+ }
548
+
549
+ // ── queryToken ──────────────────────────────────────────────────────────
550
+
551
+ export function queryToken(graph: GraphFragment, name: string): TokenQueryResult | null {
552
+ const token = graph.nodes.find(
553
+ (n): n is TokenNode => n.entity_type === "token" && n.name === name,
554
+ );
555
+ if (!token) return null;
556
+
557
+ let derived_from_axis_id: string | null = null;
558
+ if (token.axis_provenance !== null) {
559
+ const edge = graph.edges.find(
560
+ (e) => e.relation === "token_derived_from" && e.source === token.id,
561
+ );
562
+ derived_from_axis_id = edge ? edge.target : null;
563
+ }
564
+
565
+ return {
566
+ token: {
567
+ id: token.id,
568
+ name: token.name,
569
+ value: token.value,
570
+ layer: token.layer,
571
+ axis_provenance: token.axis_provenance,
572
+ category: token.category,
573
+ },
574
+ derived_from_axis_id,
575
+ };
576
+ }
577
+
578
+ // ── ScreenFullQueryResult ───────────────────────────────────────────────
579
+
580
+ export interface ScreenFullQueryResult {
581
+ screen: { id: string; label: string; description: string };
582
+ owning_features: string[];
583
+ states_visible_here: { id: string; label: string }[];
584
+ page_spec: {
585
+ id: string;
586
+ wireframe_text: string;
587
+ content_hierarchy: string[];
588
+ route: string | null;
589
+ } | null;
590
+ sections: { id: string; section_name: string; order: number; prose: string }[];
591
+ screen_state_slots: { state_id: string; appearance_text: string }[];
592
+ component_uses: {
593
+ slot: string;
594
+ position_in_wireframe: string;
595
+ prop_overrides: string;
596
+ manifest_entry?: { library: string; variant: string; hard_gate: boolean; source_ref: string | null };
597
+ }[];
598
+ key_copy: { text: string; placement: string }[];
599
+ tokens_used: { name: string; value: string; layer: string }[];
600
+ }
601
+
602
+ // ── queryScreenFull ─────────────────────────────────────────────────────
603
+
604
+ export function queryScreenFull(graph: GraphFragment, screen_id: string): ScreenFullQueryResult | null {
605
+ const screen = graph.nodes.find(
606
+ (n): n is ScreenNode => n.entity_type === "screen" && n.id === screen_id,
607
+ );
608
+ if (!screen) return null;
609
+
610
+ const owning_features = [...screen.feature_ids].sort();
611
+
612
+ const allStates = nodesOfType<StateNode>(graph.nodes, "state");
613
+ const seen = new Set<string>();
614
+ const states_visible_here: { id: string; label: string }[] = [];
615
+ for (const fid of owning_features) {
616
+ for (const s of allStates) {
617
+ if (s.feature_id === fid && !seen.has(s.id)) {
618
+ seen.add(s.id);
619
+ states_visible_here.push({ id: s.id, label: s.label });
620
+ }
621
+ }
622
+ }
623
+ states_visible_here.sort(byId);
624
+
625
+ const screenResult = { id: screen.id, label: screen.label, description: screen.description };
626
+
627
+ const pageSpec = graph.nodes.find(
628
+ (n): n is PageSpecNode => n.entity_type === "page_spec" && n.screen_id === screen_id,
629
+ );
630
+
631
+ if (!pageSpec) {
632
+ return {
633
+ screen: screenResult,
634
+ owning_features,
635
+ states_visible_here,
636
+ page_spec: null,
637
+ sections: [],
638
+ screen_state_slots: [],
639
+ component_uses: [],
640
+ key_copy: [],
641
+ tokens_used: [],
642
+ };
643
+ }
644
+
645
+ const sections = nodesOfType<WireframeSectionNode>(graph.nodes, "wireframe_section")
646
+ .filter((s) => s.parent_page_spec_id === pageSpec.id)
647
+ .sort((a, b) => a.order - b.order)
648
+ .map((s) => ({ id: s.id, section_name: s.section_name, order: s.order, prose: s.prose }));
649
+
650
+ const owningFeatureSet = new Set(owning_features);
651
+
652
+ function resolveStateLabel(stateLabel: string): string {
653
+ for (const s of allStates) {
654
+ if (kebab(s.label) === stateLabel && owningFeatureSet.has(s.feature_id)) return s.id;
655
+ }
656
+ return stateLabel;
657
+ }
658
+
659
+ const screen_state_slots = nodesOfType<ScreenStateSlotNode>(graph.nodes, "screen_state_slot")
660
+ .filter((s) => s.screen_id === screen_id)
661
+ .sort(byId)
662
+ .map((s) => ({ state_id: resolveStateLabel(s.state_id), appearance_text: s.appearance_text }));
663
+
664
+ const manifestNodes = nodesOfType<ComponentManifestEntryNode>(graph.nodes, "component_manifest_entry");
665
+ const manifestBySlot = new Map<string, ComponentManifestEntryNode>();
666
+ for (const m of manifestNodes) manifestBySlot.set(m.slot, m);
667
+
668
+ const componentUseNodes = nodesOfType<ScreenComponentUseNode>(graph.nodes, "screen_component_use")
669
+ .filter((c) => c.screen_id === screen_id)
670
+ .sort((a, b) => (a.slot < b.slot ? -1 : a.slot > b.slot ? 1 : 0));
671
+
672
+ const component_uses: ScreenFullQueryResult["component_uses"] = componentUseNodes.map((c) => {
673
+ const entry: ScreenFullQueryResult["component_uses"][number] = {
674
+ slot: c.slot,
675
+ position_in_wireframe: c.position_in_wireframe,
676
+ prop_overrides: c.prop_overrides,
677
+ };
678
+ const manifest = manifestBySlot.get(c.slot);
679
+ if (manifest) {
680
+ entry.manifest_entry = {
681
+ library: manifest.library,
682
+ variant: manifest.variant,
683
+ hard_gate: manifest.hard_gate,
684
+ source_ref: manifest.source_ref,
685
+ };
686
+ }
687
+ return entry;
688
+ });
689
+
690
+ const key_copy = nodesOfType<KeyCopyNode>(graph.nodes, "key_copy")
691
+ .filter((k) => k.screen_id === screen_id)
692
+ .sort(byId)
693
+ .map((k) => ({ text: k.text, placement: k.placement }));
694
+
695
+ const bracedRe = /\{([a-z][a-zA-Z0-9._-]*)\}/g;
696
+ const prefixRe = /\btokens\.([a-z][a-zA-Z0-9._-]*)/g;
697
+ const tokenNames = new Set<string>();
698
+ for (const c of componentUseNodes) {
699
+ for (const re of [bracedRe, prefixRe]) {
700
+ re.lastIndex = 0;
701
+ let m: RegExpExecArray | null;
702
+ while ((m = re.exec(c.prop_overrides)) !== null) {
703
+ tokenNames.add(m[1]);
704
+ }
705
+ }
706
+ }
707
+
708
+ const allTokens = nodesOfType<TokenNode>(graph.nodes, "token");
709
+ const tokenByName = new Map<string, TokenNode>();
710
+ for (const t of allTokens) tokenByName.set(t.name, t);
711
+
712
+ const tokens_used: ScreenFullQueryResult["tokens_used"] = [];
713
+ for (const name of [...tokenNames].sort()) {
714
+ const t = tokenByName.get(name);
715
+ if (t) tokens_used.push({ name: t.name, value: t.value, layer: t.layer });
716
+ }
717
+
718
+ return {
719
+ screen: screenResult,
720
+ owning_features,
721
+ states_visible_here,
722
+ page_spec: {
723
+ id: pageSpec.id,
724
+ wireframe_text: pageSpec.wireframe_text,
725
+ content_hierarchy: pageSpec.content_hierarchy,
726
+ route: pageSpec.route,
727
+ },
728
+ sections,
729
+ screen_state_slots,
730
+ component_uses,
731
+ key_copy,
732
+ tokens_used,
733
+ };
734
+ }
735
+
736
+ // ── DependenciesQueryResult ─────────────────────────────────────────────
737
+
738
+ export interface DependenciesQueryResult {
739
+ feature: { id: string; label: string };
740
+ provides: Array<{ endpoint: string; module: string; auth_required: boolean }>;
741
+ consumes: Array<{ endpoint: string; module: string; auth_required: boolean }>;
742
+ depended_on_by_features: string[];
743
+ depends_on_features: string[];
744
+ task_dag: Array<{
745
+ task_id: string;
746
+ title: string;
747
+ size: "S" | "M" | "L";
748
+ depends_on: string[];
749
+ behavioral_test: string;
750
+ assigned_phase: string;
751
+ owns_files: string[];
752
+ }>;
753
+ }
754
+
755
+ // ── queryDependencies ───────────────────────────────────────────────────
756
+
757
+ export function queryDependencies(graph: GraphFragment, feature_id: string): DependenciesQueryResult | null {
758
+ const feature = graph.nodes.find(
759
+ (n): n is FeatureNode => n.entity_type === "feature" && n.id === feature_id,
760
+ );
761
+ if (!feature) return null;
762
+
763
+ const contractById = new Map<string, ApiContractNode>();
764
+ for (const n of nodesOfType<ApiContractNode>(graph.nodes, "api_contract")) {
765
+ contractById.set(n.id, n);
766
+ }
767
+
768
+ const taskById = new Map<string, TaskNode>();
769
+ for (const n of nodesOfType<TaskNode>(graph.nodes, "task")) {
770
+ taskById.set(n.id, n);
771
+ }
772
+
773
+ const toEndpointEntry = (contractId: string) => {
774
+ const c = contractById.get(contractId);
775
+ if (!c) return null;
776
+ return { endpoint: c.endpoint, module: c.module_id, auth_required: c.auth_required };
777
+ };
778
+
779
+ const provides = graph.edges
780
+ .filter((e) => e.relation === "feature_provides_endpoint" && e.source === feature_id)
781
+ .map((e) => toEndpointEntry(e.target))
782
+ .filter((x): x is NonNullable<typeof x> => x !== null)
783
+ .sort((a, b) => (a.endpoint < b.endpoint ? -1 : a.endpoint > b.endpoint ? 1 : 0));
784
+
785
+ const consumes = graph.edges
786
+ .filter((e) => e.relation === "feature_consumes_endpoint" && e.source === feature_id)
787
+ .map((e) => toEndpointEntry(e.target))
788
+ .filter((x): x is NonNullable<typeof x> => x !== null)
789
+ .sort((a, b) => (a.endpoint < b.endpoint ? -1 : a.endpoint > b.endpoint ? 1 : 0));
790
+
791
+ const depends_on_features = graph.edges
792
+ .filter((e) => e.relation === "depends_on" && e.source === feature_id)
793
+ .map((e) => e.target)
794
+ .sort();
795
+
796
+ const depended_on_by_features = graph.edges
797
+ .filter((e) => e.relation === "depends_on" && e.target === feature_id)
798
+ .map((e) => e.source)
799
+ .sort();
800
+
801
+ const task_dag = graph.edges
802
+ .filter((e) => e.relation === "task_implements_feature" && e.target === feature_id)
803
+ .map((e) => taskById.get(e.source))
804
+ .filter((t): t is TaskNode => t !== undefined)
805
+ .map((t) => {
806
+ const depends_on = graph.edges
807
+ .filter((e) => e.relation === "task_depends_on" && e.source === t.id)
808
+ .map((e) => {
809
+ const dep = taskById.get(e.target);
810
+ return dep ? dep.task_id : null;
811
+ })
812
+ .filter((x): x is string => x !== null)
813
+ .sort();
814
+ return {
815
+ task_id: t.task_id,
816
+ title: t.title,
817
+ size: t.size,
818
+ depends_on,
819
+ behavioral_test: t.behavioral_test,
820
+ assigned_phase: t.assigned_phase,
821
+ owns_files: t.owns_files,
822
+ };
823
+ })
824
+ .sort((a, b) => (a.task_id < b.task_id ? -1 : a.task_id > b.task_id ? 1 : 0));
825
+
826
+ return {
827
+ feature: { id: feature.id, label: feature.label },
828
+ provides,
829
+ consumes,
830
+ depended_on_by_features,
831
+ depends_on_features,
832
+ task_dag,
833
+ };
834
+ }
835
+
836
+ // ── CrossContractsQueryResult ───────────────────────────────────────────
837
+
838
+ export interface CrossContractsQueryResult {
839
+ contract: {
840
+ endpoint: string;
841
+ module_id: string;
842
+ request_schema: string;
843
+ response_schema: string;
844
+ auth_required: boolean;
845
+ error_codes: string[];
846
+ };
847
+ providing_feature: string | null;
848
+ consumers: string[];
849
+ }
850
+
851
+ // ── queryCrossContracts ─────────────────────────────────────────────────
852
+
853
+ export function queryCrossContracts(graph: GraphFragment, endpoint: string): CrossContractsQueryResult | null {
854
+ const contract = graph.nodes.find(
855
+ (n): n is ApiContractNode => n.entity_type === "api_contract" && n.endpoint === endpoint,
856
+ );
857
+ if (!contract) return null;
858
+
859
+ const providers = graph.edges
860
+ .filter((e) => e.relation === "feature_provides_endpoint" && e.target === contract.id)
861
+ .map((e) => e.source)
862
+ .sort();
863
+ const providing_feature = providers.length > 0 ? providers[0] : null;
864
+
865
+ const consumers = [
866
+ ...new Set(
867
+ graph.edges
868
+ .filter((e) => e.relation === "feature_consumes_endpoint" && e.target === contract.id)
869
+ .map((e) => e.source),
870
+ ),
871
+ ].sort();
872
+
873
+ return {
874
+ contract: {
875
+ endpoint: contract.endpoint,
876
+ module_id: contract.module_id,
877
+ request_schema: contract.request_schema,
878
+ response_schema: contract.response_schema,
879
+ auth_required: contract.auth_required,
880
+ error_codes: contract.error_codes,
881
+ },
882
+ providing_feature,
883
+ consumers,
884
+ };
885
+ }
886
+
887
+ // ── DecisionFilter / DecisionView ───────────────────────────────────────
888
+
889
+ export interface DecisionFilter {
890
+ status?: "open" | "triggered" | "resolved";
891
+ phase?: string;
892
+ decided_by?: string;
893
+ }
894
+
895
+ export interface DecisionView {
896
+ id: string;
897
+ decision_id: string;
898
+ summary: string;
899
+ decided_by: string;
900
+ related_decision_id: string | null;
901
+ revisit_criterion: string | null;
902
+ status: "open" | "triggered" | "resolved";
903
+ phase: string;
904
+ step_id: string | null;
905
+ related_decision?: { id: string; summary: string; status: string };
906
+ superseded_by?: { id: string; summary: string };
907
+ }
908
+
909
+ // ── queryDecisions ──────────────────────────────────────────────────────
910
+
911
+ export function queryDecisions(graph: GraphFragment, filter: DecisionFilter): DecisionView[] {
912
+ const allDecisions = nodesOfType<DecisionNode>(graph.nodes, "decision");
913
+
914
+ const decisionByNodeId = new Map<string, DecisionNode>();
915
+ const decisionByDecisionId = new Map<string, DecisionNode>();
916
+ for (const d of allDecisions) {
917
+ decisionByNodeId.set(d.id, d);
918
+ decisionByDecisionId.set(d.decision_id, d);
919
+ }
920
+
921
+ const matched = allDecisions.filter((d) => {
922
+ if (filter.status !== undefined && d.status !== filter.status) return false;
923
+ if (filter.phase !== undefined && d.phase !== filter.phase) return false;
924
+ if (filter.decided_by !== undefined && d.decided_by !== filter.decided_by) return false;
925
+ return true;
926
+ });
927
+
928
+ const views: DecisionView[] = matched.map((d) => {
929
+ const view: DecisionView = {
930
+ id: d.id,
931
+ decision_id: d.decision_id,
932
+ summary: d.summary,
933
+ decided_by: d.decided_by,
934
+ related_decision_id: d.related_decision_id,
935
+ revisit_criterion: d.revisit_criterion,
936
+ status: d.status,
937
+ phase: d.phase,
938
+ step_id: d.step_id,
939
+ };
940
+
941
+ if (d.related_decision_id !== null) {
942
+ const related = decisionByDecisionId.get(d.related_decision_id);
943
+ if (related) {
944
+ view.related_decision = { id: related.id, summary: related.summary, status: related.status };
945
+ }
946
+ }
947
+
948
+ if (d.status === "open" || d.status === "triggered") {
949
+ const candidates = allDecisions
950
+ .filter((o) => o.related_decision_id === d.decision_id && o.status === "resolved")
951
+ .sort(byId);
952
+ if (candidates.length > 0) {
953
+ view.superseded_by = { id: candidates[0].id, summary: candidates[0].summary };
954
+ }
955
+ }
956
+
957
+ return view;
958
+ });
959
+
960
+ return views.sort((a, b) => (a.decision_id < b.decision_id ? -1 : a.decision_id > b.decision_id ? 1 : 0));
961
+ }
962
+
963
+ // ── ScreenshotQueryResult ───────────────────────────────────────────────
964
+
965
+ export interface ScreenshotQueryResult {
966
+ screenshot: {
967
+ id: string;
968
+ image_path: string;
969
+ image_class: "reference" | "brand_drift" | "dogfood";
970
+ caption: string;
971
+ perceptual_hash: string;
972
+ dominant_palette: string[];
973
+ image_dimensions: string;
974
+ dna_axis_tags: string[];
975
+ };
976
+ linked_screen?: { id: string; label: string };
977
+ linked_finding?: { id: string; severity: string; description: string };
978
+ drift_observations?: Array<{
979
+ id: string;
980
+ axis: string;
981
+ score: number;
982
+ verdict: string;
983
+ paired_screenshot: { id: string; image_class: string };
984
+ }>;
985
+ component_detections?: Array<{ id: string; component_label: string; bounding_box: string | null; detection_confidence: number | null }>;
986
+ }
987
+
988
+ // ── queryScreenshot ─────────────────────────────────────────────────────
989
+
990
+ export function queryScreenshot(graph: GraphFragment, screenshot_id: string): ScreenshotQueryResult | null {
991
+ const shot = graph.nodes.find(
992
+ (n): n is ScreenshotNode => n.entity_type === "screenshot" && n.id === screenshot_id,
993
+ );
994
+ if (!shot) return null;
995
+
996
+ const result: ScreenshotQueryResult = {
997
+ screenshot: {
998
+ id: shot.id,
999
+ image_path: shot.image_path,
1000
+ image_class: shot.image_class,
1001
+ caption: shot.caption,
1002
+ perceptual_hash: shot.perceptual_hash,
1003
+ dominant_palette: shot.dominant_palette,
1004
+ image_dimensions: shot.image_dimensions,
1005
+ dna_axis_tags: shot.dna_axis_tags,
1006
+ },
1007
+ };
1008
+
1009
+ if (shot.linked_screen_id !== null) {
1010
+ const screen = graph.nodes.find(
1011
+ (n): n is ScreenNode => n.entity_type === "screen" && n.id === shot.linked_screen_id,
1012
+ );
1013
+ if (screen) result.linked_screen = { id: screen.id, label: screen.label };
1014
+ }
1015
+
1016
+ if (shot.linked_finding_id !== null) {
1017
+ const finding = graph.nodes.find(
1018
+ (n): n is DogfoodFindingNode => n.entity_type === "dogfood_finding" && n.id === shot.linked_finding_id,
1019
+ );
1020
+ if (finding) result.linked_finding = { id: finding.id, severity: finding.severity, description: finding.description };
1021
+ }
1022
+
1023
+ const allObservations = nodesOfType<BrandDriftObservationNode>(graph.nodes, "brand_drift_observation");
1024
+ const driftObs: ScreenshotQueryResult["drift_observations"] = [];
1025
+ for (const obs of allObservations) {
1026
+ let pairedId: string | null = null;
1027
+ if (obs.prod_screenshot_id === screenshot_id) pairedId = obs.reference_screenshot_id;
1028
+ else if (obs.reference_screenshot_id === screenshot_id) pairedId = obs.prod_screenshot_id;
1029
+ else continue;
1030
+
1031
+ const paired = graph.nodes.find(
1032
+ (n): n is ScreenshotNode => n.entity_type === "screenshot" && n.id === pairedId,
1033
+ );
1034
+ if (!paired) continue;
1035
+
1036
+ driftObs.push({
1037
+ id: obs.id,
1038
+ axis: obs.axis,
1039
+ score: obs.score,
1040
+ verdict: obs.verdict,
1041
+ paired_screenshot: { id: paired.id, image_class: paired.image_class },
1042
+ });
1043
+ }
1044
+ if (driftObs.length > 0) {
1045
+ result.drift_observations = driftObs.sort(byId);
1046
+ }
1047
+
1048
+ const detectionMap = new Map<string, { id: string; component_label: string; bounding_box: string | null; detection_confidence: number | null }>();
1049
+
1050
+ for (const edge of graph.edges) {
1051
+ if (edge.relation === "image_has_component_detection" && edge.source === screenshot_id) {
1052
+ const det = graph.nodes.find(
1053
+ (n): n is ImageComponentDetectionNode => n.entity_type === "image_component_detection" && n.id === edge.target,
1054
+ );
1055
+ if (det) {
1056
+ detectionMap.set(det.id, { id: det.id, component_label: det.component_label, bounding_box: det.bounding_box, detection_confidence: det.detection_confidence });
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ // Defensive fallback: include detection nodes whose screenshot_id field points here
1062
+ for (const det of nodesOfType<ImageComponentDetectionNode>(graph.nodes, "image_component_detection")) {
1063
+ if (det.screenshot_id === screenshot_id && !detectionMap.has(det.id)) {
1064
+ detectionMap.set(det.id, { id: det.id, component_label: det.component_label, bounding_box: det.bounding_box, detection_confidence: det.detection_confidence });
1065
+ }
1066
+ }
1067
+
1068
+ if (detectionMap.size > 0) {
1069
+ result.component_detections = [...detectionMap.values()].sort(byId);
1070
+ }
1071
+
1072
+ return result;
1073
+ }
1074
+
1075
+ // ── SimilarQueryResult ──────────────────────────────────────────────────
1076
+
1077
+ export interface SimilarQueryResult {
1078
+ query_screenshot: { id: string; perceptual_hash: string };
1079
+ matches: Array<{
1080
+ screenshot_id: string;
1081
+ image_class: string;
1082
+ perceptual_hash: string;
1083
+ distance: number;
1084
+ }>;
1085
+ }
1086
+
1087
+ // ── queryScreenshotSimilar ──────────────────────────────────────────────
1088
+
1089
+ export function queryScreenshotSimilar(
1090
+ graph: GraphFragment,
1091
+ screenshot_id: string,
1092
+ threshold: number = 5,
1093
+ ): SimilarQueryResult | null {
1094
+ const query = graph.nodes.find(
1095
+ (n): n is ScreenshotNode => n.entity_type === "screenshot" && n.id === screenshot_id,
1096
+ );
1097
+ if (!query) return null;
1098
+
1099
+ const allShots = nodesOfType<ScreenshotNode>(graph.nodes, "screenshot");
1100
+ const matches: SimilarQueryResult["matches"] = [];
1101
+ for (const other of allShots) {
1102
+ if (other.id === screenshot_id) continue;
1103
+ const distance = hammingDistance(query.perceptual_hash, other.perceptual_hash);
1104
+ if (distance <= threshold) {
1105
+ matches.push({ screenshot_id: other.id, image_class: other.image_class, perceptual_hash: other.perceptual_hash, distance });
1106
+ }
1107
+ }
1108
+ matches.sort((a, b) => a.distance - b.distance || (a.screenshot_id < b.screenshot_id ? -1 : a.screenshot_id > b.screenshot_id ? 1 : 0));
1109
+
1110
+ return { query_screenshot: { id: query.id, perceptual_hash: query.perceptual_hash }, matches };
1111
+ }
1112
+
1113
+ // ── BrandDriftQueryResult ───────────────────────────────────────────────
1114
+
1115
+ export interface BrandDriftQueryResult {
1116
+ observations: Array<{
1117
+ id: string;
1118
+ prod_screenshot_id: string;
1119
+ reference_screenshot_id: string;
1120
+ prod_screenshot: { id: string; image_class: string; perceptual_hash: string } | null;
1121
+ reference_screenshot: { id: string; image_class: string; perceptual_hash: string } | null;
1122
+ axis: string;
1123
+ score: number;
1124
+ verdict: "drift" | "ok" | "needs-review";
1125
+ }>;
1126
+ }
1127
+
1128
+ // ── queryBrandDrift ─────────────────────────────────────────────────────
1129
+
1130
+ export function queryBrandDrift(graph: GraphFragment): BrandDriftQueryResult {
1131
+ const screenshotById = new Map<string, ScreenshotNode>();
1132
+ for (const n of nodesOfType<ScreenshotNode>(graph.nodes, "screenshot")) {
1133
+ screenshotById.set(n.id, n);
1134
+ }
1135
+ const resolveShot = (id: string) => {
1136
+ const s = screenshotById.get(id);
1137
+ return s ? { id: s.id, image_class: s.image_class, perceptual_hash: s.perceptual_hash } : null;
1138
+ };
1139
+
1140
+ const observations = nodesOfType<BrandDriftObservationNode>(graph.nodes, "brand_drift_observation")
1141
+ .map((o) => ({
1142
+ id: o.id,
1143
+ prod_screenshot_id: o.prod_screenshot_id,
1144
+ reference_screenshot_id: o.reference_screenshot_id,
1145
+ prod_screenshot: resolveShot(o.prod_screenshot_id),
1146
+ reference_screenshot: resolveShot(o.reference_screenshot_id),
1147
+ axis: o.axis,
1148
+ score: o.score,
1149
+ verdict: o.verdict,
1150
+ }))
1151
+ .sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1152
+
1153
+ return { observations };
1154
+ }