@hybridlabor-api/bdb-synapse 1.0.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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,491 @@
1
+ import { useCallback, useState, type ReactNode } from "react";
2
+ import { AlertTriangle, RefreshCw, Sparkles, X } from "lucide-react";
3
+ import type {
4
+ JudgeChoice,
5
+ ReportDimension,
6
+ ReportFinding,
7
+ ReportStatus,
8
+ Rubric,
9
+ RubricCriterion,
10
+ RubricTask,
11
+ Severity,
12
+ Verdict
13
+ } from "../types";
14
+
15
+ interface ReportPanelProps {
16
+ status?: ReportStatus;
17
+ analyzing: boolean;
18
+ locked: boolean;
19
+ onAnalyze: (choice: JudgeChoice) => void;
20
+ onClose: () => void;
21
+ /** jump the playhead to an evidence seq and focus its file in the scene */
22
+ onJumpTo: (seq: number) => void;
23
+ }
24
+
25
+ const DIMENSION_WORDS: Record<string, { title: string; hint: string }> = {
26
+ exploration: { title: "Exploration", hint: "Did the agent build enough understanding before editing?" },
27
+ scope: { title: "Scope", hint: "Does the footprint match what the task needed?" },
28
+ wandering: { title: "Wandering", hint: "Purposeful path, or circles and dead ends?" },
29
+ verification: { title: "Verification", hint: "Were edits verified, and errors followed up?" }
30
+ };
31
+
32
+ /** the mainstream models each judge CLI can be pinned to; "" keeps its default */
33
+ const JUDGE_MODELS: Record<string, { value: string; label: string }[]> = {
34
+ claude: [
35
+ { value: "", label: "default model" },
36
+ { value: "sonnet", label: "sonnet" },
37
+ { value: "opus", label: "opus" },
38
+ { value: "fable", label: "fable" }
39
+ ],
40
+ codex: [
41
+ { value: "", label: "default model" },
42
+ { value: "gpt-5.6-sol", label: "gpt-5.6 sol" },
43
+ { value: "gpt-5.6-terra", label: "gpt-5.6 terra" }
44
+ ]
45
+ };
46
+
47
+ const JUDGE_CHOICE_KEY = "mindwalk:judge-choice";
48
+
49
+ function loadStoredChoice(): JudgeChoice {
50
+ try {
51
+ const raw = localStorage.getItem(JUDGE_CHOICE_KEY);
52
+ if (raw) {
53
+ const parsed = JSON.parse(raw) as Partial<JudgeChoice>;
54
+ return {
55
+ cli: typeof parsed.cli === "string" ? parsed.cli : "",
56
+ model: typeof parsed.model === "string" ? parsed.model : ""
57
+ };
58
+ }
59
+ } catch {
60
+ // corrupt storage reads as "no preference"
61
+ }
62
+ return { cli: "", model: "" };
63
+ }
64
+
65
+ /** clamp a stored choice to what is actually installed and offered */
66
+ function resolveChoice(choice: JudgeChoice, clis: string[]): JudgeChoice {
67
+ const cli = clis.includes(choice.cli) ? choice.cli : (clis[0] ?? "");
68
+ const models = JUDGE_MODELS[cli] ?? [];
69
+ const model = models.some((m) => m.value === choice.model) ? choice.model : "";
70
+ return { cli, model };
71
+ }
72
+
73
+ // dock panel content: the session evaluation. The Dock owns positioning;
74
+ // this owns only its own markup.
75
+ export function ReportPanel({ status, analyzing, locked, onAnalyze, onClose, onJumpTo }: ReportPanelProps) {
76
+ // the judge choice persists across sessions and reloads; the picker shows
77
+ // wherever a run can start (empty, failed, stale)
78
+ const [storedChoice, setStoredChoice] = useState<JudgeChoice>(loadStoredChoice);
79
+ const clis = status?.judgeClis ?? (status?.judgeCli ? [status.judgeCli] : []);
80
+ const choice = resolveChoice(storedChoice, clis);
81
+ const changeChoice = useCallback((next: JudgeChoice) => {
82
+ setStoredChoice(next);
83
+ try {
84
+ localStorage.setItem(JUDGE_CHOICE_KEY, JSON.stringify(next));
85
+ } catch {
86
+ // storage full or unavailable — the selection still applies this session
87
+ }
88
+ }, []);
89
+ const analyze = useCallback(() => onAnalyze(choice), [onAnalyze, choice]);
90
+
91
+ return (
92
+ <div className="dock-body" aria-label="Session evaluation">
93
+ <div className="inspector-head">
94
+ <div>
95
+ <div className="inspector-path">Evaluation</div>
96
+ {status?.report ? (
97
+ <div className="report-meta">
98
+ judged by {status.report.judge.cli}
99
+ {status.report.judge.model ? ` · ${status.report.judge.model}` : ""} ·{" "}
100
+ {day(status.report.judge.generatedAt)}
101
+ </div>
102
+ ) : null}
103
+ </div>
104
+ <button className="icon-btn" onClick={onClose} title="Close" aria-label="Close evaluation">
105
+ <X size={15} />
106
+ </button>
107
+ </div>
108
+ <PanelBody
109
+ status={status}
110
+ analyzing={analyzing}
111
+ locked={locked}
112
+ analyze={analyze}
113
+ onJumpTo={onJumpTo}
114
+ picker={clis.length > 0 ? <JudgePicker clis={clis} choice={choice} onChange={changeChoice} /> : null}
115
+ />
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function JudgePicker({
121
+ clis,
122
+ choice,
123
+ onChange
124
+ }: {
125
+ clis: string[];
126
+ choice: JudgeChoice;
127
+ onChange: (choice: JudgeChoice) => void;
128
+ }) {
129
+ const models = JUDGE_MODELS[choice.cli] ?? [{ value: "", label: "default model" }];
130
+ return (
131
+ <div className="report-picker">
132
+ <select
133
+ value={choice.cli}
134
+ onChange={(e) => onChange({ cli: e.target.value, model: "" })}
135
+ aria-label="Judge agent"
136
+ title="Which agent CLI judges this session"
137
+ >
138
+ {clis.map((cli) => (
139
+ <option key={cli} value={cli}>
140
+ {cli}
141
+ </option>
142
+ ))}
143
+ </select>
144
+ <select
145
+ value={choice.model}
146
+ onChange={(e) => onChange({ ...choice, model: e.target.value })}
147
+ aria-label="Judge model"
148
+ title="Which model the judge runs on"
149
+ >
150
+ {models.map((model) => (
151
+ <option key={model.value} value={model.value}>
152
+ {model.label}
153
+ </option>
154
+ ))}
155
+ </select>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function PanelBody({
161
+ status,
162
+ analyzing,
163
+ locked,
164
+ analyze,
165
+ onJumpTo,
166
+ picker
167
+ }: {
168
+ status?: ReportStatus;
169
+ analyzing: boolean;
170
+ locked: boolean;
171
+ analyze: () => void;
172
+ onJumpTo: (seq: number) => void;
173
+ picker: ReactNode;
174
+ }) {
175
+ if (!status) {
176
+ return <p className="report-note">Checking for an existing report…</p>;
177
+ }
178
+ if (status.state === "running" || analyzing) {
179
+ return (
180
+ <div className="report-note">
181
+ <p className="report-running">Judging the trajectory…</p>
182
+ <p>
183
+ The judge first drafts task-specific criteria from your request, then scores the session against
184
+ them plus four process dimensions. Usually a minute or two; you can keep exploring meanwhile.
185
+ </p>
186
+ </div>
187
+ );
188
+ }
189
+ if (status.state === "failed") {
190
+ return (
191
+ <div className="report-note">
192
+ <p className="report-error">
193
+ <AlertTriangle size={13} /> Evaluation failed
194
+ </p>
195
+ <p className="report-error-detail">{status.error}</p>
196
+ {picker}
197
+ <button className="report-run" onClick={analyze}>
198
+ <RefreshCw size={13} />
199
+ Retry
200
+ </button>
201
+ </div>
202
+ );
203
+ }
204
+ if (status.state === "none" || !status.report) {
205
+ if (!status.judgeAvailable) {
206
+ return (
207
+ <p className="report-note">
208
+ Evaluation needs a local agent CLI as judge. Install <code>claude</code> or <code>codex</code> and
209
+ make it available on PATH.
210
+ </p>
211
+ );
212
+ }
213
+ return (
214
+ <div className="report-note">
215
+ <p>
216
+ Ask an agent to evaluate this session: how it explored, whether the footprint matched the task,
217
+ where it wandered, and how it verified its work. Every finding links back to the timeline.
218
+ </p>
219
+ {picker}
220
+ <button className="report-run" onClick={analyze}>
221
+ <Sparkles size={13} />
222
+ Evaluate session
223
+ </button>
224
+ <p className="report-cost">
225
+ Runs the selected CLI under your own account and sends it a summary of this session — task wording,
226
+ file paths, event digests — for the model to read. About a minute.
227
+ </p>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ const report = status.report;
233
+ return (
234
+ <div className="report-body">
235
+ <div className="report-controls">
236
+ {status.stale ? (
237
+ <p className="report-stale-note">
238
+ Based on {report.session.eventCount} events — the session has grown since.
239
+ </p>
240
+ ) : null}
241
+ <div className="report-stale-actions">
242
+ {picker}
243
+ <button
244
+ className="report-rerun"
245
+ onClick={analyze}
246
+ title={status.stale ? "Re-evaluate with the current trace" : "Run a fresh evaluation of this session"}
247
+ >
248
+ <RefreshCw size={12} />
249
+ Re-evaluate
250
+ </button>
251
+ </div>
252
+ </div>
253
+ {/* the lede: one line of what was asked, then the judge's overview —
254
+ the report reads summary-first, details after */}
255
+ <p className="report-task">{report.taskSummary}</p>
256
+ <p className="report-narrative">{report.narrative}</p>
257
+ <RubricSection rubric={report.rubric} locked={locked} onJumpTo={onJumpTo} />
258
+ <p className="report-chapter">Process</p>
259
+ {report.dimensions.map((dimension) => (
260
+ <Dimension key={dimension.name} dimension={dimension} locked={locked} onJumpTo={onJumpTo} />
261
+ ))}
262
+ {report.notableMoments?.length ? (
263
+ <section className="report-section">
264
+ <p className="eyebrow">Moments</p>
265
+ {report.notableMoments.map((moment) => (
266
+ <button
267
+ key={moment.seq}
268
+ className="report-moment"
269
+ onClick={() => onJumpTo(moment.seq)}
270
+ disabled={locked}
271
+ title={`Jump to step ${moment.seq + 1}`}
272
+ >
273
+ <strong>#{moment.seq + 1}</strong>
274
+ <span>{moment.note}</span>
275
+ </button>
276
+ ))}
277
+ </section>
278
+ ) : null}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ // the task-accounting layer: rubric criteria grouped by task, between the
284
+ // task summary and the four process dimensions. Absent or unavailable
285
+ // rubrics collapse to a single quiet line — the fixed layer never waits.
286
+ function RubricSection({
287
+ rubric,
288
+ locked,
289
+ onJumpTo
290
+ }: {
291
+ rubric?: Rubric;
292
+ locked: boolean;
293
+ onJumpTo: (seq: number) => void;
294
+ }) {
295
+ if (!rubric) {
296
+ return <p className="report-rubric-note">This report has no task rubric — re-evaluate to add one.</p>;
297
+ }
298
+ if (rubric.status !== "scored" || !rubric.tasks?.length) {
299
+ const text =
300
+ rubric.reason === "generation-failed"
301
+ ? "Task rubric unavailable this run — showing process dimensions only."
302
+ : rubric.reason === "no-events"
303
+ ? "No tool events to evidence a rubric."
304
+ : "Not enough task text to build a rubric from.";
305
+ return <p className="report-rubric-note">{text}</p>;
306
+ }
307
+ const tasks = rubric.tasks;
308
+ const criteria = tasks.flatMap((task) => task.criteria);
309
+ const thin = criteria.filter((criterion) => criterion.coverage && criterion.coverage !== "sufficient");
310
+ const showHint = criteria.length > 0 && thin.length / criteria.length > 0.4;
311
+ const multi = tasks.length > 1;
312
+ return (
313
+ <div className="report-rubric">
314
+ <p className="report-chapter">Tasks</p>
315
+ {showHint ? (
316
+ <p className="report-rubric-hint">
317
+ {thin.length} of {criteria.length} criteria had thin evidence — the log may not show enough to
318
+ judge them.
319
+ </p>
320
+ ) : null}
321
+ {tasks.map((task, i) => (
322
+ // anchor seqs are validated disjoint across tasks, so the first one is
323
+ // a unique stable key; titles are LLM-authored and may collide
324
+ <RubricTaskBlock
325
+ key={task.anchorSeqs?.[0] ?? `task-${i}`}
326
+ task={task}
327
+ multi={multi}
328
+ locked={locked}
329
+ onJumpTo={onJumpTo}
330
+ />
331
+ ))}
332
+ {rubric.note ? (
333
+ <div className="report-rubric-footnote">
334
+ <p className="eyebrow">Rubric note</p>
335
+ <p>{rubric.note}</p>
336
+ </div>
337
+ ) : null}
338
+ </div>
339
+ );
340
+ }
341
+
342
+ function RubricTaskBlock({
343
+ task,
344
+ multi,
345
+ locked,
346
+ onJumpTo
347
+ }: {
348
+ task: RubricTask;
349
+ multi: boolean;
350
+ locked: boolean;
351
+ onJumpTo: (seq: number) => void;
352
+ }) {
353
+ const startSeq = task.anchorSeqs?.[0];
354
+ return (
355
+ <section className="report-rubric-task">
356
+ {multi ? (
357
+ // single-task sessions skip the header: the criteria read like a
358
+ // fifth..nth dimension and the layout stays close to what it was
359
+ <button
360
+ className="rubric-task-head"
361
+ onClick={() => {
362
+ if (startSeq !== undefined) onJumpTo(startSeq);
363
+ }}
364
+ disabled={locked || startSeq === undefined}
365
+ title={startSeq !== undefined ? `Jump to this task's start (step ${startSeq + 1})` : undefined}
366
+ >
367
+ {/* no state dot here: each criterion below carries its own verdict
368
+ chip, and severity dots stay the panel's only dot vocabulary */}
369
+ <span className="rubric-task-title">
370
+ {task.title}
371
+ {task.type ? <span className="rubric-task-type">{task.type}</span> : null}
372
+ </span>
373
+ </button>
374
+ ) : null}
375
+ {task.criteria.map((criterion) => (
376
+ <Criterion key={criterion.id} criterion={criterion} locked={locked} onJumpTo={onJumpTo} />
377
+ ))}
378
+ </section>
379
+ );
380
+ }
381
+
382
+ // a criterion renders exactly like a dimension — same head, chip, and
383
+ // finding buttons — plus a muted coverage badge when evidence ran thin
384
+ function Criterion({
385
+ criterion,
386
+ locked,
387
+ onJumpTo
388
+ }: {
389
+ criterion: RubricCriterion;
390
+ locked: boolean;
391
+ onJumpTo: (seq: number) => void;
392
+ }) {
393
+ const hint = [
394
+ criterion.why,
395
+ criterion.good ? `good: ${criterion.good}` : "",
396
+ criterion.bad ? `bad: ${criterion.bad}` : ""
397
+ ]
398
+ .filter(Boolean)
399
+ .join("\n");
400
+ return (
401
+ <section className="report-dimension report-criterion">
402
+ <div className="report-dimension-head">
403
+ <span className="report-criterion-name" title={hint || undefined}>
404
+ {criterion.title}
405
+ </span>
406
+ <span className="report-criterion-badges">
407
+ {criterion.coverage === "partial" ? <span className="coverage-badge">partial evidence</span> : null}
408
+ <span
409
+ className={`verdict verdict-${criterion.verdict}`}
410
+ title={
411
+ criterion.coverage === "none"
412
+ ? "The log cannot evidence this criterion either way"
413
+ : undefined
414
+ }
415
+ >
416
+ {verdictWord(criterion.verdict)}
417
+ </span>
418
+ </span>
419
+ </div>
420
+ {criterion.findings.map((finding) => (
421
+ <FindingButton key={`${finding.severity}|${finding.evidenceSeqs?.join(",")}|${finding.claim}`} finding={finding} locked={locked} onJumpTo={onJumpTo} />
422
+ ))}
423
+ </section>
424
+ );
425
+ }
426
+
427
+ function FindingButton({
428
+ finding,
429
+ locked,
430
+ onJumpTo
431
+ }: {
432
+ finding: ReportFinding;
433
+ locked: boolean;
434
+ onJumpTo: (seq: number) => void;
435
+ }) {
436
+ return (
437
+ <button
438
+ className="report-finding"
439
+ onClick={() => {
440
+ const seq = finding.evidenceSeqs?.[0];
441
+ if (seq !== undefined) onJumpTo(seq);
442
+ }}
443
+ disabled={locked || !finding.evidenceSeqs?.length}
444
+ title={
445
+ finding.evidenceSeqs?.length
446
+ ? `Jump to step ${finding.evidenceSeqs[0] + 1} — evidence: ${finding.evidenceSeqs.map((seq) => `#${seq + 1}`).join(" ")}`
447
+ : undefined
448
+ }
449
+ >
450
+ <span className={`severity-dot ${severityClass(finding.severity)}`} />
451
+ <span className="report-claim">{finding.claim}</span>
452
+ </button>
453
+ );
454
+ }
455
+
456
+ function Dimension({
457
+ dimension,
458
+ locked,
459
+ onJumpTo
460
+ }: {
461
+ dimension: ReportDimension;
462
+ locked: boolean;
463
+ onJumpTo: (seq: number) => void;
464
+ }) {
465
+ const words = DIMENSION_WORDS[dimension.name] ?? { title: dimension.name, hint: "" };
466
+ return (
467
+ <section className="report-dimension">
468
+ <div className="report-dimension-head" data-hint={words.hint}>
469
+ <span className="report-dimension-name">{words.title}</span>
470
+ <span className={`verdict verdict-${dimension.verdict}`}>{verdictWord(dimension.verdict)}</span>
471
+ </div>
472
+ {dimension.findings.map((finding) => (
473
+ <FindingButton key={`${finding.severity}|${finding.evidenceSeqs?.join(",")}|${finding.claim}`} finding={finding} locked={locked} onJumpTo={onJumpTo} />
474
+ ))}
475
+ </section>
476
+ );
477
+ }
478
+
479
+ function verdictWord(verdict: Verdict): string {
480
+ return verdict === "insufficient-data" ? "no signal" : verdict;
481
+ }
482
+
483
+ function severityClass(severity: Severity): string {
484
+ return `sev-${severity}`;
485
+ }
486
+
487
+ function day(iso: string): string {
488
+ const d = new Date(iso);
489
+ if (Number.isNaN(d.getTime())) return "";
490
+ return d.toISOString().slice(0, 10);
491
+ }