This project includes deployment configs plus browser-side export helpers for shareable demos.
+ >)}
);
}
+
+function ViewModeTabs({ value, onChange }) {
+ const tabs = [
+ { id: 'home', label: 'Home', hint: 'Brain + quick scan' },
+ { id: 'scan', label: 'Scan', hint: 'Firewall + Coverage + Tone + Archive' },
+ { id: 'diagnose', label: 'Diagnose', hint: 'Harness diagnostic stack (L102–114)' },
+ { id: 'all', label: 'All panels', hint: 'Every layer, full power' },
+ ];
+ return (
+
+ {tabs.map((t) => (
+ onChange(t.id)}
+ title={t.hint}
+ >
+ {t.label}
+
+ ))}
+
+ ⌘K jumps to any layer
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/AutoStewardPanel.jsx b/brainsnn-r3f-app/src/components/AutoStewardPanel.jsx
new file mode 100644
index 0000000..295740e
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/AutoStewardPanel.jsx
@@ -0,0 +1,173 @@
+import React, { useEffect, useState } from 'react';
+import {
+ subscribe, getStatus, updateConfig, start, stop, runOnce,
+ revertApplied, clearAppliedLog,
+} from '../utils/autoSteward';
+
+/**
+ * Layer 103 — Auto-Apply Rule Steward panel.
+ *
+ * Closes the HALO loop: poll the harness report, derive a candidate
+ * rule diff, and apply low-risk additions automatically. Defaults to
+ * dryRun + paused so nothing happens until the operator explicitly
+ * arms it.
+ */
+export default function AutoStewardPanel() {
+ const [status, setStatus] = useState(() => getStatus());
+
+ useEffect(() => subscribe(setStatus), []);
+
+ const cfg = status.config;
+ const tone = (
+ status.lastReportTier === 'critical' ? '#dd6974'
+ : status.lastReportTier === 'warn' ? '#fdab43'
+ : '#5ee69a'
+ );
+
+ function field(key, parser = (v) => v) {
+ return (e) => updateConfig({ [key]: parser(e.target.value) });
+ }
+
+ return (
+
+ Layer 103 · auto-apply rule steward
+ Self-driving Layer 102 loop
+
+ Each cycle, pull the harness report, derive a rule diff, and
+ apply low-risk additions to Layer 55. Default config is
+ dry-run + manual — flip enabled to arm it. Quota
+ + lift threshold prevent runaway rule growth. Every applied
+ rule is reversible from the log below.
+
+
+
+
+
+ {status.running ? 'RUNNING' : 'IDLE'} · last tier {status.lastReportTier}
+
+
+ cycle {status.cycleCount} · {status.lastDiffAdditions} candidates · {status.appliedLog.length} applied
+
+
+
+
+
+ {status.running ? (
+ Stop
+ ) : (
+ Start
+ )}
+ Run once
+
+ updateConfig({ enabled: e.target.checked })}
+ />
+ enabled
+
+
+ updateConfig({ dryRun: e.target.checked })}
+ />
+ dry run
+
+
+
+
+
+
+
+
+
+
+
+ {status.lastDiffFollowUps.length > 0 && (
+
+
Suggested follow-ups (manual)
+ {status.lastDiffFollowUps.map((f, i) => (
+
+
L{f.layer} · {f.action}
+
{f.reason}
+
+ ))}
+
+ )}
+
+
+
+
Applied log
+ {status.appliedLog.length > 0 && (
+
Clear
+ )}
+
+ {status.appliedLog.length === 0 ? (
+
Nothing applied yet.
+ ) : status.appliedLog.map((e, i) => (
+
+
+
+ {e.category}
+ {' '}/{e.pattern}/
+ {e.dryRun && · dry-run }
+ {e.error && · {e.error} }
+
+ {e.ruleId && !e.reverted && !e.dryRun && (
+ revertApplied(e.ruleId)}>Revert
+ )}
+ {e.reverted && reverted }
+
+
+ {new Date(e.ts).toLocaleString()} · cycle {e.cycle} · {e.source}
+
+
+ ))}
+
+
+ );
+}
+
+function NumberInput({ label, value, onChange, ...rest }) {
+ return (
+
+ {label}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/ContentProvenancePanel.jsx b/brainsnn-r3f-app/src/components/ContentProvenancePanel.jsx
new file mode 100644
index 0000000..7b4b1d5
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/ContentProvenancePanel.jsx
@@ -0,0 +1,500 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ isCryptoAvailable,
+ ensureIdentity,
+ loadIdentity,
+ loadHandle,
+ setHandle,
+ fingerprintFor,
+ signContent,
+ appendEdit,
+ verifyManifest,
+ recentManifestLog,
+ logManifest,
+ clearManifestLog,
+ manifestToShareString,
+ tryParseManifest,
+ humanityScore,
+} from '../utils/contentProvenance';
+
+/**
+ * Layer 101 — Content Verification System panel.
+ *
+ * Three tabs:
+ * - Sign: capture context, sign payload, output a manifest
+ * - Verify: paste a manifest + payload, get pass/fail + chain view
+ * - Humanity: run the heuristic humanity score on a draft
+ *
+ * The whole thing runs in-browser — keys never leave the device.
+ */
+export default function ContentProvenancePanel() {
+ const supported = isCryptoAvailable();
+ const [tab, setTab] = useState('sign');
+ const [identity, setIdentity] = useState(() => loadIdentity());
+ const [handle, setHandleState] = useState(() => loadHandle());
+ const [fp, setFp] = useState('');
+
+ useEffect(() => {
+ let alive = true;
+ if (identity?.pub) {
+ fingerprintFor(identity.pub).then((f) => { if (alive) setFp(f); });
+ }
+ return () => { alive = false; };
+ }, [identity]);
+
+ async function handleGenerate() {
+ if (!supported) return;
+ const r = await ensureIdentity({ handle });
+ if (r.ok) {
+ setIdentity(loadIdentity());
+ setFp(r.identity.fingerprint);
+ }
+ }
+
+ function handleHandleChange(e) {
+ const v = e.target.value;
+ setHandleState(v);
+ setHandle(v);
+ }
+
+ return (
+
+ Layer 101 · content verification
+ Sign your humanity
+
+ AI-perfect imagery is now cheap. The signal that cuts through is
+ a verifiable chain of custody — who captured it, on what device,
+ and which edits followed. Sign your content with a local keypair.
+ Anyone can verify your post hasn't been swapped for a fake.
+
+
+
+
+
+
+
+
+
+
+
+ {tab === 'sign' && }
+ {tab === 'verify' && }
+ {tab === 'humanity' && }
+ {tab === 'log' && }
+
+ );
+}
+
+function Tab({ id, tab, setTab, label }) {
+ const on = tab === id;
+ return (
+ setTab(id)}
+ style={on ? {} : { opacity: 0.7 }}
+ >
+ {label}
+
+ );
+}
+
+function IdentityCard({ supported, identity, fingerprint, handle, onHandle, onGenerate }) {
+ if (!supported) {
+ return (
+
+
Web Crypto unavailable.
+
+ Signing needs SubtleCrypto, which requires HTTPS or localhost.
+ The Humanity tab still works.
+
+
+ );
+ }
+
+ const hasIdentity = !!identity?.priv && !!identity?.pub;
+ const tone = hasIdentity ? '#5ee69a' : '#fdab43';
+
+ return (
+
+
+ {hasIdentity ? 'Creator key ready' : 'No creator key yet'}
+
+ {hasIdentity ? 'ECDSA P-256 · stored locally' : 'click to generate'}
+
+
+
+
+
+ {hasIdentity ? 'Rotate key' : 'Generate key'}
+
+
+ {fingerprint && (
+
+ fp · {fingerprint.slice(0, 16)}…
+
+ )}
+
+ );
+}
+
+function SignTab({ supported, identity }) {
+ const [payload, setPayload] = useState('');
+ const [device, setDevice] = useState('');
+ const [location, setLocation] = useState('');
+ const [note, setNote] = useState('');
+ const [manifest, setManifest] = useState(null);
+ const [editNote, setEditNote] = useState('');
+ const [err, setErr] = useState('');
+
+ const ready = supported && identity?.priv;
+
+ async function doSign() {
+ setErr('');
+ if (!ready) { setErr('Generate a key first'); return; }
+ if (!payload.trim()) { setErr('Paste content to sign'); return; }
+ const r = await signContent({
+ payload,
+ payloadKind: 'text',
+ device,
+ location,
+ note,
+ });
+ if (!r.ok) { setErr(r.reason || 'sign failed'); return; }
+ setManifest(r.manifest);
+ logManifest({ manifest: r.manifest, payloadExcerpt: payload });
+ }
+
+ async function doAppendEdit() {
+ setErr('');
+ if (!manifest) return;
+ const r = await appendEdit({ manifest, payload, note: editNote });
+ if (!r.ok) { setErr(r.reason || 'edit failed'); return; }
+ setManifest(r.manifest);
+ setEditNote('');
+ logManifest({ manifest: r.manifest, payloadExcerpt: payload });
+ }
+
+ function copyManifest() {
+ if (!manifest) return;
+ try { navigator.clipboard.writeText(manifestToShareString(manifest)); } catch { /* noop */ }
+ }
+
+ return (
+
+ );
+}
+
+function ManifestView({ manifest, onAppend, editNote, setEditNote, payload }) {
+ const last = manifest.chain[manifest.chain.length - 1];
+ return (
+
+
Manifest · {manifest.chain.length} step{manifest.chain.length === 1 ? '' : 's'}
+
+ fp {manifest.fingerprint.slice(0, 12)}… · last sig {last.sig.slice(0, 12)}…
+
+
+ {manifest.chain.map((s, i) => (
+
+ {s.kind}
+ {s.note ? ` · ${s.note}` : ''} · h={s.hash.slice(0, 10)}…
+ {s.prevHash ? ` ← ${s.prevHash.slice(0, 8)}…` : ' (origin)'}
+
+ ))}
+
+
+
+ setEditNote(e.target.value)}
+ maxLength={200}
+ />
+ Append edit
+
+
+ );
+}
+
+function VerifyTab({ supported }) {
+ const [manifestStr, setManifestStr] = useState('');
+ const [payload, setPayload] = useState('');
+ const [result, setResult] = useState(null);
+ const [err, setErr] = useState('');
+
+ async function doVerify() {
+ setErr('');
+ setResult(null);
+ if (!supported) { setErr('Web Crypto unavailable'); return; }
+ const m = tryParseManifest(manifestStr);
+ if (!m) { setErr('Manifest is not valid JSON or has unknown version'); return; }
+ const r = await verifyManifest({ manifest: m, payload });
+ setResult(r);
+ }
+
+ const tone = !result ? '#7a8fe7' : result.ok ? '#5ee69a' : '#dd6974';
+
+ return (
+
+ );
+}
+
+function HumanityTab() {
+ const [text, setText] = useState('');
+ const result = useMemo(() => humanityScore(text), [text]);
+ const tone = (
+ result.score >= 65 ? '#5ee69a'
+ : result.score >= 45 ? '#fdab43'
+ : '#dd6974'
+ );
+
+ return (
+
+
setText(e.target.value)}
+ />
+
+
+
+ {result.score}/100 · {result.tier}
+ heuristic — not proof
+
+
+ {Object.entries(result.axes || {}).map(([k, v]) => (
+
+ ))}
+
+ {result.evidence && result.evidence.length > 0 && (
+
+ {result.evidence.map((e, i) => (
+
+ {e.axis} ×{e.hits} — {e.note}
+
+ ))}
+
+ )}
+
+
+
+ Reads polished? Add a hedge, a detail only you'd notice, a typo
+ you'd actually leave in. The "humanity" axis is what stands out
+ in a feed of perfect copy.
+
+
+ );
+}
+
+function LogTab() {
+ const [log, setLog] = useState(() => recentManifestLog());
+
+ function refresh() { setLog(recentManifestLog()); }
+ function clearAll() {
+ if (!window.confirm('Clear the local manifest log?')) return;
+ clearManifestLog();
+ refresh();
+ }
+
+ return (
+
+
+ Refresh
+ Clear
+
+ {log.length === 0 ? (
+
No manifests issued yet.
+ ) : log.map((entry) => (
+
+
+
+ {entry.handle || 'unsigned-handle'} · {entry.payloadKind} · {entry.stepCount} step{entry.stepCount === 1 ? '' : 's'}
+
+ {new Date(entry.ts).toLocaleString()}
+
+
+ fp {entry.fingerprint.slice(0, 12)}… · payload {entry.payloadHash.slice(0, 12)}…
+
+ {entry.excerpt &&
“{entry.excerpt}”
}
+
+ ))}
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/DiagnosticSnapshotsPanel.jsx b/brainsnn-r3f-app/src/components/DiagnosticSnapshotsPanel.jsx
new file mode 100644
index 0000000..2d8db66
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/DiagnosticSnapshotsPanel.jsx
@@ -0,0 +1,125 @@
+import React, { useEffect, useState } from 'react';
+import {
+ loadConfig, saveConfig, start, stop, isActive, timeline, clearTimeline, pruneAuto,
+} from '../utils/diagnosticSnapshots';
+
+/**
+ * Layer 113 — Diagnostic Snapshots panel.
+ */
+export default function DiagnosticSnapshotsPanel() {
+ const [cfg, setCfg] = useState(() => loadConfig());
+ const [running, setRunning] = useState(() => isActive());
+ const [tl, setTl] = useState(() => timeline());
+
+ useEffect(() => {
+ const id = setInterval(() => {
+ setRunning(isActive());
+ setTl(timeline());
+ }, 3000);
+ return () => clearInterval(id);
+ }, []);
+
+ function update(partial) {
+ setCfg(saveConfig(partial));
+ if (partial.enabled === false && running) stop();
+ if (partial.enabled === true && !running) start();
+ setRunning(isActive());
+ }
+ function arm() { start(); setRunning(isActive()); }
+ function disarm() { stop(); setRunning(isActive()); }
+ function clearAll() {
+ if (!window.confirm('Clear timeline + delete auto-snapshots?')) return;
+ pruneAuto();
+ setTl([]);
+ }
+
+ return (
+
+ Layer 113 · diagnostic snapshots
+ Auto-snap on tier shifts
+
+ Watches the live span buffer and saves a Layer 104 snapshot
+ every time the harness tier changes. Builds a longitudinal
+ timeline so "when did we go critical?" has a real answer.
+ Manual Layer 104 snapshots are untouched.
+
+
+
+ {running ? 'WATCHING' : 'IDLE'}
+
+ timeline · {tl.length} entries
+
+
+
+
+ {running ? (
+ Stop watching
+ ) : (
+ Start watching
+ )}
+
+ update({ enabled: e.target.checked })}
+ />
+ enabled
+
+
+ debounce ms
+ update({ debounceMs: Number(e.target.value) })}
+ style={{ width: 100 }}
+ />
+
+ {tl.length > 0 && (
+
+ Prune auto
+
+ )}
+
+
+
+
Tier-shift timeline
+ {tl.length === 0 ? (
+
No shifts yet.
+ ) : tl.map((e) => (
+
+
+ {e.fromTier} → {e.toTier}
+
+ {new Date(e.ts).toLocaleString()}
+
+
+ {e.findingIds.length > 0 && (
+
{e.findingIds.join(', ')}
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/FocusedDiagnoseDeck.jsx b/brainsnn-r3f-app/src/components/FocusedDiagnoseDeck.jsx
new file mode 100644
index 0000000..36d249e
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/FocusedDiagnoseDeck.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import HarnessDiagnosticPanel from './HarnessDiagnosticPanel';
+import HarnessAlertsPanel from './HarnessAlertsPanel';
+import AutoStewardPanel from './AutoStewardPanel';
+import HarnessComparatorPanel from './HarnessComparatorPanel';
+import DiagnosticSnapshotsPanel from './DiagnosticSnapshotsPanel';
+import SpanDistributionPanel from './SpanDistributionPanel';
+import TraceSearchPanel from './TraceSearchPanel';
+import McpToolUsagePanel from './McpToolUsagePanel';
+import SpanAnnotationPanel from './SpanAnnotationPanel';
+import TraceReplayPanel from './TraceReplayPanel';
+import OtlpExporterPanel from './OtlpExporterPanel';
+import SanitizerPanel from './SanitizerPanel';
+
+/**
+ * Layer 102.5 — Focused: Diagnose
+ *
+ * The HALO observability stack rendered together: report, alerts,
+ * auto-steward, comparator, scrubber, search, exporter, sanitizer.
+ */
+export default function FocusedDiagnoseDeck() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/FocusedScanDeck.jsx b/brainsnn-r3f-app/src/components/FocusedScanDeck.jsx
new file mode 100644
index 0000000..edcd47c
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/FocusedScanDeck.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import CognitiveFirewallPanel from './CognitiveFirewallPanel';
+import CoveragePanel from './CoveragePanel';
+import ToneShifterPanel from './ToneShifterPanel';
+import ContextMemoryPanel from './ContextMemoryPanel';
+import ScanArchivePanel from './ScanArchivePanel';
+import ContentProvenancePanel from './ContentProvenancePanel';
+
+/**
+ * Layer 102.5 — Focused: Scan
+ *
+ * Renders just the scan-flow panels: paste, score, see coverage,
+ * shift tone, archive, sign provenance. Same components as the
+ * full surface — only one viewMode is mounted at a time, so this
+ * doesn't double-render.
+ */
+export default function FocusedScanDeck({ firewallProps }) {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/HarnessAlertsPanel.jsx b/brainsnn-r3f-app/src/components/HarnessAlertsPanel.jsx
new file mode 100644
index 0000000..a66c9eb
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/HarnessAlertsPanel.jsx
@@ -0,0 +1,102 @@
+import React, { useEffect, useState } from 'react';
+import {
+ loadConfig, saveConfig, start, stop, isActive, getStatus,
+} from '../utils/harnessAlerts';
+
+/**
+ * Layer 109 — Harness Alerts panel.
+ *
+ * Subscribe to span activity, run the diagnostic, toast on tier shift
+ * or new findings. Off by default. Pairs with Layer 9 (Toast).
+ */
+export default function HarnessAlertsPanel() {
+ const [cfg, setCfg] = useState(() => loadConfig());
+ const [running, setRunning] = useState(() => isActive());
+ const [status, setStatus] = useState(() => getStatus());
+
+ useEffect(() => {
+ const id = setInterval(() => {
+ setRunning(isActive());
+ setStatus(getStatus());
+ }, 2000);
+ return () => clearInterval(id);
+ }, []);
+
+ function update(partial) {
+ const next = saveConfig(partial);
+ setCfg(next);
+ if (running && partial.enabled === false) stop();
+ if (!running && partial.enabled === true) start();
+ setRunning(isActive());
+ }
+ function arm() { start(); setRunning(isActive()); }
+ function disarm() { stop(); setRunning(isActive()); }
+
+ return (
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/HarnessComparatorPanel.jsx b/brainsnn-r3f-app/src/components/HarnessComparatorPanel.jsx
new file mode 100644
index 0000000..88a5a83
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/HarnessComparatorPanel.jsx
@@ -0,0 +1,178 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ listSnapshots, saveSnapshot, deleteSnapshot, clearSnapshots,
+ compareReports, renderDiffText,
+} from '../utils/harnessComparator';
+import { runDiagnostic } from '../utils/harnessFailureModes';
+
+/**
+ * Layer 104 — Harness Comparator panel.
+ *
+ * "Did my last rule change actually help?" Snapshot the current
+ * report (Layer 102), make a change, snapshot again, then diff.
+ */
+export default function HarnessComparatorPanel() {
+ const [snaps, setSnaps] = useState(() => listSnapshots());
+ const [aId, setA] = useState('');
+ const [bId, setB] = useState('');
+ const [label, setLabel] = useState('');
+
+ function refresh() { setSnaps(listSnapshots()); }
+
+ function snap() {
+ const report = runDiagnostic();
+ saveSnapshot({ label: label || `snap-${snaps.length + 1}`, report });
+ setLabel('');
+ refresh();
+ }
+ function del(id) { deleteSnapshot(id); refresh(); }
+
+ const a = useMemo(() => snaps.find((s) => s.id === aId), [snaps, aId]);
+ const b = useMemo(() => snaps.find((s) => s.id === bId), [snaps, bId]);
+ const diff = useMemo(() => (a && b ? compareReports(a.report, b.report) : null), [a, b]);
+ const text = useMemo(() => (diff ? renderDiffText(diff) : ''), [diff]);
+
+ function copy() {
+ if (!text) return;
+ try { navigator.clipboard.writeText(text); } catch { /* noop */ }
+ }
+
+ const tone = (
+ !diff ? '#7a8fe7'
+ : diff.tier?.shift === 'improved' ? '#5ee69a'
+ : diff.tier?.shift === 'regressed' ? '#dd6974'
+ : '#fdab43'
+ );
+
+ useEffect(() => { refresh(); }, []);
+
+ return (
+
+ Layer 104 · harness comparator
+ Before / after the change
+
+ Snapshot the live harness report (Layer 102), apply a rule
+ change, snapshot again, then diff. Surfaces which findings
+ appeared, resolved, or changed severity — and which ops got
+ slower or noisier.
+
+
+
+ setLabel(e.target.value)}
+ maxLength={40}
+ />
+ Snapshot now
+ { clearSnapshots(); refresh(); }} style={{ color: '#dd6974' }}>
+ Clear all
+
+
+
+ {snaps.length === 0 ? (
+ No snapshots yet.
+ ) : (
+
+
+
+
+
+
+ {snaps.map((s) => (
+
+
+ {s.label}
+
+ {s.report.tier} · {s.report.totals.spans} spans · {s.report.findings.length} findings
+
+
+
+ {new Date(s.ts).toLocaleString()}
+ del(s.id)} style={{ marginLeft: 6, color: '#dd6974' }}>
+ Delete
+
+
+
+ ))}
+
+
+ )}
+
+ {diff && diff.ok && (
+
+
+
+ {diff.tier.from} → {diff.tier.to} · {diff.tier.shift.toUpperCase()}
+
+ Copy diff
+
+
+ spans Δ{diff.totals.spans.delta} · errors Δ{diff.totals.errors.delta}
+
+ {diff.findings.added.length > 0 && (
+
`+ ${f.label} ×${f.count}`)} color="#dd6974" />
+ )}
+ {diff.findings.removed.length > 0 && (
+ `- ${f.label} ×${f.count}`)} color="#5ee69a" />
+ )}
+ {diff.findings.shifted.length > 0 && (
+ `~ ${f.label} · ${f.severity.from}→${f.severity.to} · ×${f.count.from}→×${f.count.to}`)}
+ color="#fdab43"
+ />
+ )}
+
+ )}
+
+ );
+}
+
+function SnapPicker({ label, value, onChange, snaps }) {
+ return (
+
+ {label}
+ onChange(e.target.value)}>
+ — choose —
+ {snaps.map((s) => (
+
+ {s.label} · {s.report.tier}
+
+ ))}
+
+
+ );
+}
+
+function DiffList({ title, items, color }) {
+ return (
+
+
{title}
+
+ {items.map((s, i) => {s} )}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/HarnessDiagnosticPanel.jsx b/brainsnn-r3f-app/src/components/HarnessDiagnosticPanel.jsx
new file mode 100644
index 0000000..4af36f7
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/HarnessDiagnosticPanel.jsx
@@ -0,0 +1,185 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ recentSpans, spanCount, clearSpans, subscribe, exportSpans, isEnabled, setEnabled,
+} from '../utils/telemetry';
+import { runDiagnostic, renderReportText } from '../utils/harnessFailureModes';
+
+/**
+ * Layer 102 — Harness Diagnostic panel.
+ *
+ * Reads the unified telemetry span buffer (telemetry.js), runs the
+ * failure-mode detectors + Laplace lift mining, and surfaces a
+ * harness-report-v1 JSON document. Operators can copy the rendered
+ * text into Cursor / Claude Code (HALO-style "fix what you can"
+ * loop), or pipe the JSON to an MCP tool.
+ */
+export default function HarnessDiagnosticPanel() {
+ const [tick, setTick] = useState(0);
+ const [enabled, setEnabledState] = useState(() => isEnabled());
+
+ useEffect(() => {
+ const unsub = subscribe(() => setTick((n) => n + 1));
+ return () => { unsub(); };
+ }, []);
+
+ const spans = useMemo(() => recentSpans(500), [tick]);
+ const report = useMemo(() => runDiagnostic({ spans }), [spans]);
+ const text = useMemo(() => renderReportText(report), [report]);
+
+ function copyText() {
+ try { navigator.clipboard.writeText(text); } catch { /* noop */ }
+ }
+ function copyJson() {
+ try { navigator.clipboard.writeText(JSON.stringify(report, null, 2)); } catch { /* noop */ }
+ }
+ function downloadSpans() {
+ const blob = new Blob([JSON.stringify(exportSpans(), null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `brainsnn-spans-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+ function toggleEnabled() {
+ const next = !enabled;
+ setEnabled(next);
+ setEnabledState(next);
+ }
+ function clear() {
+ if (!window.confirm('Clear the local span buffer?')) return;
+ clearSpans();
+ setTick((n) => n + 1);
+ }
+
+ const tone = (
+ report.tier === 'critical' ? '#dd6974'
+ : report.tier === 'warn' ? '#fdab43'
+ : '#5ee69a'
+ );
+
+ return (
+
+ Layer 102 · harness diagnostic
+ Self-diagnose the brain
+
+ Reads the telemetry span buffer (cannibalized OpenTelemetry
+ schema) and clusters spans into named failure modes — slow
+ scans, hung MCP, refusal loops, dead patterns, FP-heavy rules.
+ Borrowed from context-labs/halo: the harness
+ watches itself, then hands a structured fix-report to a coding
+ agent. Copy the report into Cursor / Claude Code or pipe the
+ JSON to an MCP tool.
+
+
+
+
+ {report.tier.toUpperCase()}
+
+ {report.totals.spans} spans · {report.totals.errors} err · {report.totals.distinctNames} ops
+
+
+ {report.findings.length === 0 ? (
+
+ No failure modes detected. Use the firewall, MCP bridge, or
+ steward — spans show up here automatically.
+
+ ) : (
+
+ {report.findings.map((f) => (
+
+ {f.label} {' '}
+ ×{f.count} · {f.severity}
+ {f.hint && (
+ {f.hint}
+ )}
+
+ ))}
+
+ )}
+
+
+
+ Copy report (text)
+ Copy report (JSON)
+
+ Download spans
+
+
+ {enabled ? 'Pause emit' : 'Resume emit'}
+
+ Clear
+
+
+ {report.errorLift?.length > 0 && (
+
+
Error-correlated features
+ {report.errorLift.map((c) => (
+
+ {c.feature}
+ lift {c.lift}
+ err {c.posCount}
+ ok {c.negCount}
+
+ ))}
+
+ )}
+
+ {report.aggregates?.length > 0 && (
+
+
Top operations
+ {report.aggregates.map((a) => (
+
+ {a.name}
+ ×{a.count}
+ p50 {a.p50}ms
+ 0 ? '#dd6974' : '' }}>
+ err {Math.round(a.errorRate * 100)}%
+
+
+ ))}
+
+ )}
+
+
+ Spans are stored locally (cap {spanCount() ? '500' : '500'}) and
+ never leave your device. Layer 57 (Data Portability) picks them
+ up alongside everything else when you back up your state.
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/HomeDeck.jsx b/brainsnn-r3f-app/src/components/HomeDeck.jsx
new file mode 100644
index 0000000..9b6c911
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/HomeDeck.jsx
@@ -0,0 +1,131 @@
+import React, { useMemo, useState } from 'react';
+import { scoreContent } from '../utils/cognitiveFirewall';
+import { getImmunityState } from '../utils/immunityScore';
+
+/**
+ * Layer 102.5 — Home (the simple view).
+ *
+ * Inline firewall scan. Immunity glance. Two doors out: focus on
+ * scanning, or open the full panel surface. No navigation required
+ * to do the most common thing.
+ */
+export default function HomeDeck({ onScan, onDiagnose, onAll }) {
+ const [text, setText] = useState('');
+ const [result, setResult] = useState(null);
+ const immunity = useMemo(() => {
+ try { return getImmunityState(); } catch { return null; }
+ }, [result]);
+
+ function runScan() {
+ if (!text.trim()) return;
+ setResult(scoreContent(text));
+ }
+
+ const tone = (
+ !result ? '#7a8fe7'
+ : result.manipulationPressure >= 0.6 ? '#dd6974'
+ : result.manipulationPressure >= 0.3 ? '#fdab43'
+ : '#5ee69a'
+ );
+ const tier = (
+ !result ? null
+ : result.manipulationPressure >= 0.6 ? 'High pressure'
+ : result.manipulationPressure >= 0.3 ? 'Tilted'
+ : 'Calm'
+ );
+
+ return (
+
+ Home · the simple view
+ Watch the brain. Scan when curious.
+
+ The 3D viewer above is the whole point. Try a quick scan below,
+ or jump into a focused workflow.
+
+
+
+
setText(e.target.value)}
+ style={{ minHeight: 80 }}
+ />
+
+
+ Quick scan
+
+ { setText(''); setResult(null); }}>
+ Clear
+
+
+ {result && (
+
+
+
+ {tier} · {Math.round(result.manipulationPressure * 100)}/100 pressure
+
+
+ {result.languageLabel || 'en'} · {(result.evidence || []).length} evidence
+
+
+ {result.recommendedAction && (
+
+ {result.recommendedAction}
+
+ )}
+ {result.templates?.length > 0 && (
+
+ {result.templates.slice(0, 5).map((t) => (
+
+ {t.label}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {immunity && (
+
+
Cognitive immunity
+
{Math.round(immunity.score || 0)}
+
/ 100 · streak {immunity.streak || 0}
+
+ )}
+
+
Quick jumps
+
+ Focus: Scan
+ Focus: Diagnose
+ All panels
+
+
+
+
+
+ Hit ⌘K for fuzzy-search jump · ? for keyboard shortcuts.
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/McpToolUsagePanel.jsx b/brainsnn-r3f-app/src/components/McpToolUsagePanel.jsx
new file mode 100644
index 0000000..4e14eb2
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/McpToolUsagePanel.jsx
@@ -0,0 +1,102 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { recentSpans, subscribe } from '../utils/telemetry';
+import { computeUsage } from '../utils/mcpToolUsage';
+
+/**
+ * Layer 114 — MCP Tool Usage panel.
+ */
+export default function McpToolUsagePanel() {
+ const [tick, setTick] = useState(0);
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+ const usage = useMemo(() => computeUsage(recentSpans(500)), [tick]);
+
+ return (
+
+ Layer 114 · MCP tool usage
+ Hot · slow · flaky · dead
+
+ Cross-references Layer 19 BRAIN_TOOLS against actual mcp.tool
+ spans in the buffer. Surfaces hot tools, slow tails, flaky
+ error rates, and dead tools (registered but never called).
+
+
+
+
+ {usage.totals.calls} calls · {usage.totals.errors} errors · {usage.totals.called} / {usage.totals.registered} tools used
+
+
+ {usage.totals.dead} dead
+
+
+
+
+
+
+
+ {usage.dead.length > 0 && (
+
+
Dead tools (registered, never called)
+ {usage.dead.slice(0, 10).map((d) => (
+
+
{d.tool}
+
{d.description}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ToolList({ title, tone, tools, columns }) {
+ if (!tools.length) return null;
+ return (
+
+
{title}
+ {tools.map((t) => (
+
'70px').join(' ')}`,
+ gap: 8,
+ padding: '4px 8px',
+ background: 'rgba(255,255,255,0.03)',
+ borderRadius: 4,
+ marginTop: 2,
+ fontFamily: 'monospace',
+ fontSize: 12,
+ }}
+ >
+ {t.tool}
+ {columns.map((c) => (
+
+ {c === 'errorRate' ? `${Math.round((t[c] || 0) * 100)}%` : (t[c] ?? 0) + (c.startsWith('p') ? 'ms' : '')}
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/OtlpExporterPanel.jsx b/brainsnn-r3f-app/src/components/OtlpExporterPanel.jsx
new file mode 100644
index 0000000..11cb5cf
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/OtlpExporterPanel.jsx
@@ -0,0 +1,153 @@
+import React, { useEffect, useState } from 'react';
+import {
+ loadConfig, saveConfig, exportSpansOnce, exportResults, clearResults,
+} from '../utils/otlpExporter';
+import { piiAuditSummary } from '../utils/telemetrySanitizer';
+import { recentSpans } from '../utils/telemetry';
+
+/**
+ * Layer 107 — OTLP Exporter panel.
+ *
+ * Push the local span buffer to any OTLP-HTTP collector
+ * (Honeycomb / Tempo / Datadog / Jaeger).
+ */
+export default function OtlpExporterPanel() {
+ const [cfg, setCfg] = useState(() => loadConfig());
+ const [results, setResults] = useState(() => exportResults());
+ const [busy, setBusy] = useState(false);
+ const [audit, setAudit] = useState(() => piiAuditSummary(recentSpans(500)));
+
+ useEffect(() => { setResults(exportResults()); }, []);
+ useEffect(() => {
+ const id = setInterval(() => setAudit(piiAuditSummary(recentSpans(500))), 5000);
+ return () => clearInterval(id);
+ }, []);
+
+ function update(partial) {
+ const next = saveConfig(partial);
+ setCfg(next);
+ }
+
+ async function pushReal() {
+ setBusy(true);
+ await exportSpansOnce();
+ setResults(exportResults());
+ setBusy(false);
+ }
+ async function pushDryRun() {
+ setBusy(true);
+ await exportSpansOnce({ dryRun: true });
+ setResults(exportResults());
+ setBusy(false);
+ }
+ function wipe() {
+ clearResults();
+ setResults([]);
+ }
+
+ return (
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/SanitizerPanel.jsx b/brainsnn-r3f-app/src/components/SanitizerPanel.jsx
new file mode 100644
index 0000000..eec9d12
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/SanitizerPanel.jsx
@@ -0,0 +1,144 @@
+import React, { useEffect, useState } from 'react';
+import {
+ STANDARD_PATTERNS, loadCustomPatterns, addCustomPattern, removeCustomPattern,
+ clearCustomPatterns, piiAuditSummary,
+} from '../utils/telemetrySanitizer';
+import { recentSpans, subscribe } from '../utils/telemetry';
+
+/**
+ * Layer 108 — Telemetry Sanitizer panel.
+ *
+ * Inspect what PII the redactor would catch in the live buffer,
+ * curate custom patterns. Layer 107 always runs through this on
+ * export — this panel is for dialing it in.
+ */
+export default function SanitizerPanel() {
+ const [tick, setTick] = useState(0);
+ const [custom, setCustom] = useState(() => loadCustomPatterns());
+ const [label, setLabel] = useState('');
+ const [source, setSource] = useState('');
+ const [err, setErr] = useState('');
+
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+ const audit = piiAuditSummary(recentSpans(500));
+
+ function add() {
+ setErr('');
+ if (!source.trim()) return;
+ try {
+ const list = addCustomPattern({ label: label.trim(), source: source.trim() });
+ setCustom(list);
+ setLabel('');
+ setSource('');
+ } catch (e) { setErr(e.message); }
+ }
+ function rm(id) {
+ removeCustomPattern(id);
+ setCustom(loadCustomPatterns());
+ }
+ function clearAll() {
+ if (!window.confirm('Clear all custom PII patterns?')) return;
+ clearCustomPatterns();
+ setCustom([]);
+ }
+
+ return (
+
+ Layer 108 · telemetry sanitizer
+ PII redaction before egress
+
+ Before any span leaves this device (Layer 107 OTLP push, Layer
+ 96 cross-device sync, Layer 57 export), every string attribute
+ runs through this redactor. Standard patterns ship for email
+ / phone / IP / SSN-like / card / bearer-token. Add custom
+ regexes for your domain (employee IDs, internal slugs).
+
+
+
+ {audit.flagged} / {audit.total} spans flagged
+ {audit.byLabel.length > 0 && (
+
+ {audit.byLabel.map((l) => `${l.label}×${l.count}`).join(' · ')}
+
+ )}
+
+
+
+
Standard patterns
+ {STANDARD_PATTERNS.map((p) => (
+
+ {p.label}
+ /{p.source}/
+
+ ))}
+
+
+
+
Custom patterns
+
+ setLabel(e.target.value)}
+ maxLength={32}
+ />
+ setSource(e.target.value)}
+ />
+ Add
+
+ {err &&
{err}
}
+
+ {custom.length === 0 ? (
+
No custom patterns yet.
+ ) : (
+ <>
+ {custom.map((p) => (
+
+
+ {p.label}
+ /{p.source}/
+
+ rm(p.id)} style={{ color: '#dd6974' }}>
+ Remove
+
+
+ ))}
+
+ Clear all custom
+
+ >
+ )}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/SpanAnnotationPanel.jsx b/brainsnn-r3f-app/src/components/SpanAnnotationPanel.jsx
new file mode 100644
index 0000000..5913ad3
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/SpanAnnotationPanel.jsx
@@ -0,0 +1,165 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { recentSpans, subscribe } from '../utils/telemetry';
+import {
+ STANDARD_LABELS, listAnnotations, annotate, unannotate, annotationStats, clearAnnotations,
+} from '../utils/spanAnnotation';
+
+/**
+ * Layer 105 — Span Annotation panel.
+ *
+ * Pick a label, click a span, attach a note. Annotations feed back
+ * into Layer 102's runDiagnostic() — operator-marked false positives
+ * become a first-class finding and a high-lift signal for the next
+ * rule diff.
+ */
+export default function SpanAnnotationPanel() {
+ const [tick, setTick] = useState(0);
+ const [filter, setFilter] = useState('');
+ const [annotMap, setAnnotMap] = useState(() => listAnnotations());
+ const [pickedLabel, setPickedLabel] = useState(STANDARD_LABELS[0].id);
+ const [note, setNote] = useState('');
+
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+ useEffect(() => { setAnnotMap(listAnnotations()); }, [tick]);
+
+ const spans = useMemo(() => recentSpans(120), [tick]);
+ const filtered = useMemo(() => {
+ const q = filter.trim().toLowerCase();
+ if (!q) return spans;
+ return spans.filter((s) => (
+ s.name.toLowerCase().includes(q)
+ || JSON.stringify(s.attributes || {}).toLowerCase().includes(q)
+ ));
+ }, [spans, filter]);
+
+ const stats = useMemo(annotationStats, [annotMap, tick]);
+
+ function tag(spanId) {
+ const created = annotate({ spanId, label: pickedLabel, note });
+ setAnnotMap({ ...annotMap, [spanId]: created });
+ setNote('');
+ }
+ function untag(spanId) {
+ unannotate(spanId);
+ const next = { ...annotMap };
+ delete next[spanId];
+ setAnnotMap(next);
+ }
+ function clearAll() {
+ if (!window.confirm('Clear all span annotations?')) return;
+ clearAnnotations();
+ setAnnotMap({});
+ }
+
+ return (
+
+ Layer 105 · span annotation
+ Label spans, sharpen the diagnostic
+
+ Mark spans as false-positive / real-bug /
+ benign. Layer 102 picks up labels on next run —
+ operator-flagged false positives surface as their own finding
+ and the lift miner uses the labels as features.
+
+
+
+ {STANDARD_LABELS.map((l) => (
+ setPickedLabel(l.id)}
+ title={l.desc}
+ style={{ borderLeft: `3px solid ${l.tone}` }}
+ >
+ {l.id}
+
+ ))}
+
+
+
+ setFilter(e.target.value)}
+ />
+ setNote(e.target.value)}
+ maxLength={200}
+ />
+
+
+ {stats.count > 0 && (
+
+
+ {stats.count} annotations ·{' '}
+ {stats.totals.map((t) => `${t.label}×${t.count}`).join(' · ')}
+
+ Clear
+
+ )}
+
+
+ {filtered.length === 0 ? (
+
No spans matching filter.
+ ) : filtered.slice(0, 60).map((s) => {
+ const a = annotMap[s.span_id];
+ const labelMeta = a ? STANDARD_LABELS.find((x) => x.id === a.label) : null;
+ const tone = labelMeta?.tone || (s.status?.code === 'error' ? '#dd6974' : '#5ee69a');
+ return (
+
+
+
+ {s.name}
+
+ {s.duration_ms ?? '?'}ms · {s.status?.code}
+
+
+
+ {a ? (
+ untag(s.span_id)}>Remove
+ ) : (
+ tag(s.span_id)}>Tag
+ )}
+
+
+
{JSON.stringify(s.attributes)}
+ {a && (
+
+ →
+ {a.label}
+ {a.note && — {a.note} }
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/SpanDistributionPanel.jsx b/brainsnn-r3f-app/src/components/SpanDistributionPanel.jsx
new file mode 100644
index 0000000..0985928
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/SpanDistributionPanel.jsx
@@ -0,0 +1,64 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { recentSpans, subscribe } from '../utils/telemetry';
+import { distributionsByName } from '../utils/spanDistribution';
+
+/**
+ * Layer 111 — Span Distribution panel.
+ */
+export default function SpanDistributionPanel() {
+ const [tick, setTick] = useState(0);
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+ const dists = useMemo(() => distributionsByName(recentSpans(500)), [tick]);
+
+ return (
+
+ Layer 111 · span distribution
+ Tail-latency over the buffer
+
+ Per-name duration histogram + p50 / p90 / p95 / p99. Spots
+ the slow tail that the aggregateByName p50/p95 alone can hide.
+
+
+ {dists.length === 0 ? (
+ No spans yet.
+ ) : dists.map((d) => (
+
+
+ {d.name}
+
+ ×{d.count} · p50 {d.p50}ms · p95 {d.p95}ms · p99 {d.p99}ms · max {d.max}ms
+
+
+
+
+ ))}
+
+ );
+}
+
+function Histogram({ dist }) {
+ const max = Math.max(1, ...dist.buckets);
+ return (
+
+ {dist.buckets.map((c, i) => {
+ const h = (c / max) * 36;
+ return (
+
+
+
{dist.labels[i]}
+
×{c}
+
+ );
+ })}
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/TraceDrivenTourPanel.jsx b/brainsnn-r3f-app/src/components/TraceDrivenTourPanel.jsx
new file mode 100644
index 0000000..11ffb49
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/TraceDrivenTourPanel.jsx
@@ -0,0 +1,83 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { recentSpans, subscribe } from '../utils/telemetry';
+import { deriveTour } from '../utils/traceDrivenTour';
+import { LAYER_GROUPS } from '../utils/layerCatalog';
+
+/**
+ * Layer 110 — Trace-Driven Tour panel.
+ *
+ * Static role tours (Layer 94) ship six steps regardless of usage.
+ * This layer reads the live span buffer and produces a personalized
+ * "next thing to try" based on which ops you've actually run.
+ */
+export default function TraceDrivenTourPanel() {
+ const [tick, setTick] = useState(0);
+
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+
+ const tour = useMemo(() => deriveTour({ spans: recentSpans(500) }), [tick]);
+
+ function jump(layerId) {
+ // Reuses the flash-panel helper convention from CommandPalette
+ if (typeof window === 'undefined') return;
+ const el = document.querySelector(`[data-layer-id="${layerId}"]`)
+ || document.querySelector('.panel'); // fallback to first panel
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ return (
+
+ Layer 110 · trace-driven tour
+ Tour built from your usage
+
+ Layer 94 ships static role tours. This layer reads the live
+ span buffer (Layer 102) and recommends the next layer based
+ on what you've actually been doing.
+
+
+
+
Usage so far
+
{tour.summary}
+
+
+
+
Try these next
+ {tour.steps.length === 0 ? (
+
No steps to recommend yet.
+ ) : tour.steps.map((s, i) => {
+ const groupColor = LAYER_GROUPS[s.group]?.color || '#7a8fe7';
+ return (
+
+
+
+ L{s.layerId} ·
+ {s.label}
+
+ jump(s.layerId)}>Open
+
+
{s.body}
+
→ {s.layerName}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/TraceReplayPanel.jsx b/brainsnn-r3f-app/src/components/TraceReplayPanel.jsx
new file mode 100644
index 0000000..d81e429
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/TraceReplayPanel.jsx
@@ -0,0 +1,176 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { recentSpans, subscribe } from '../utils/telemetry';
+import { buildFrames, rollForward } from '../utils/traceReplay';
+
+/**
+ * Layer 106 — Trace Replay panel.
+ *
+ * Scrub the live span buffer like a recording. Pick a frame size,
+ * play / pause / step. Surface per-name accumulated totals as the
+ * replay walks forward.
+ */
+export default function TraceReplayPanel() {
+ const [tick, setTick] = useState(0);
+ const [frameMs, setFrameMs] = useState(2000);
+ const [speed, setSpeed] = useState(1);
+ const [idx, setIdx] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const timerRef = useRef(null);
+
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+
+ const spans = useMemo(() => recentSpans(500), [tick]);
+ const { frames, from, to } = useMemo(
+ () => buildFrames({ spans, frameMs }),
+ [spans, frameMs],
+ );
+
+ // Clamp index when frames change
+ useEffect(() => {
+ if (idx >= frames.length) setIdx(Math.max(0, frames.length - 1));
+ }, [frames.length, idx]);
+
+ // Drive playback
+ useEffect(() => {
+ if (!playing) {
+ if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
+ return;
+ }
+ timerRef.current = setInterval(() => {
+ setIdx((i) => {
+ if (i >= frames.length - 1) {
+ setPlaying(false);
+ return i;
+ }
+ return i + 1;
+ });
+ }, Math.max(50, 600 / speed));
+ return () => clearInterval(timerRef.current);
+ }, [playing, frames.length, speed]);
+
+ const cursor = frames[idx];
+ const acc = useMemo(() => rollForward(frames, idx), [frames, idx]);
+
+ const totalSpan = (to && from) ? to - from : 0;
+ const progress = (idx + 1) / Math.max(1, frames.length);
+
+ return (
+
+ Layer 106 · trace replay
+ Scrub the buffer
+
+ Walk the unified telemetry buffer like a recording. Useful for
+ "show me what the harness was doing during the outage at 14:32"
+ without re-running the brain. Frame size sets the bucket.
+
+
+
+
setFrameMs(Number(e.target.value))} min={100} step={500} />
+
+
+ {playing ? (
+ setPlaying(false)}>Pause
+ ) : (
+ setPlaying(true)} disabled={frames.length === 0}>Play
+ )}
+ setIdx((i) => Math.max(0, i - 1))} disabled={idx === 0}>‹
+ setIdx((i) => Math.min(frames.length - 1, i + 1))} disabled={idx >= frames.length - 1}>›
+ setIdx(0)}>↺
+
+
+
+ setIdx(Number(e.target.value))}
+ style={{ width: '100%', marginTop: 12 }}
+ disabled={frames.length === 0}
+ />
+
+
+ frame {idx + 1} / {frames.length} · {Math.round(progress * 100)}%
+
+ window {totalSpan ? Math.round(totalSpan / 1000) + 's' : '0s'} · {spans.length} spans
+
+
+
+ {cursor && (
+
+
{new Date(cursor.tStart).toLocaleTimeString()}
+
+ {cursor.stats.count} spans · {cursor.stats.errors} err · {cursor.stats.names.join(', ') || '(idle)'}
+
+
+ {cursor.spans.slice(0, 8).map((s) => (
+
+ {s.name} · {s.duration_ms ?? '?'}ms
+ {s.attributes?.tool && ` · tool=${s.attributes.tool}`}
+
+ ))}
+ {cursor.spans.length > 8 && … +{cursor.spans.length - 8} more }
+
+
+ )}
+
+ {acc.byName.length > 0 && (
+
+
Cumulative through cursor
+ {acc.byName.slice(0, 10).map((b) => (
+
+ {b.name}
+ ×{b.count}
+ err {b.errors}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function NumberInput({ label, value, onChange, ...rest }) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function SpeedPicker({ speed, onChange }) {
+ return (
+
+ speed
+ onChange(Number(e.target.value))}>
+ 0.5×
+ 1×
+ 2×
+ 4×
+ 8×
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/components/TraceSearchPanel.jsx b/brainsnn-r3f-app/src/components/TraceSearchPanel.jsx
new file mode 100644
index 0000000..d3d1cd5
--- /dev/null
+++ b/brainsnn-r3f-app/src/components/TraceSearchPanel.jsx
@@ -0,0 +1,69 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { recentSpans, subscribe } from '../utils/telemetry';
+import { searchSpans, parseQuery } from '../utils/traceSearch';
+
+/**
+ * Layer 112 — Trace Search panel.
+ */
+export default function TraceSearchPanel() {
+ const [tick, setTick] = useState(0);
+ const [q, setQ] = useState('');
+ useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
+
+ const spans = useMemo(() => recentSpans(500), [tick]);
+ const clauses = useMemo(() => parseQuery(q), [q]);
+ const hits = useMemo(() => searchSpans(q, spans), [q, spans]);
+
+ return (
+
+ Layer 112 · trace search
+ Query the span buffer
+
+ Mini query language: name=firewall.scan status=error duration>200.
+ Bare words match free-text against name + JSON-stringified
+ attributes. Operators: = / != / > / < / >= / <=.
+
+
+ setQ(e.target.value)}
+ style={{ marginTop: 10 }}
+ />
+
+ {q && (
+
+ parsed: {clauses.map((c) => c.kind === 'op' ? `${c.field}${c.op}${c.value}` : `"${c.value}"`).join(' · ')}
+
+ )}
+
+
+ {hits.length} of {spans.length} match
+
+
+
+ {hits.slice(0, 80).map((s) => (
+
+
+ {s.name}
+ {s.duration_ms ?? '?'}ms · {s.status?.code}
+
+
{JSON.stringify(s.attributes)}
+
+ ))}
+
+
+ );
+}
diff --git a/brainsnn-r3f-app/src/styles/global.css b/brainsnn-r3f-app/src/styles/global.css
index 27d340c..e91fbd9 100644
--- a/brainsnn-r3f-app/src/styles/global.css
+++ b/brainsnn-r3f-app/src/styles/global.css
@@ -3075,3 +3075,67 @@ h1,h2,h3{
.lower-grid,.metric-grid,.inspector-grid,.export-panel .export-grid{grid-template-columns:1fr}
.inline-bar{width:70px}
}
+
+/* ---------- Layer 102.5 — View Mode Tabs (Home / All panels) ---------- */
+.view-mode-tabs{
+ display:flex;
+ align-items:center;
+ gap:6px;
+ margin:14px 0 4px;
+ padding:6px;
+ background:rgba(255,255,255,.03);
+ border:1px solid rgba(255,255,255,.06);
+ border-radius:12px;
+}
+.view-mode-tab{
+ appearance:none;
+ border:1px solid transparent;
+ background:transparent;
+ color:var(--muted);
+ padding:7px 14px;
+ border-radius:8px;
+ font:inherit;
+ font-weight:600;
+ font-size:.85rem;
+ cursor:pointer;
+ letter-spacing:.01em;
+ transition:background-color .15s ease, color .15s ease, border-color .15s ease;
+}
+.view-mode-tab:hover{color:var(--text);background:rgba(255,255,255,.04)}
+.view-mode-tab.is-active{
+ background:rgba(122,143,231,.18);
+ color:var(--text);
+ border-color:rgba(122,143,231,.4);
+}
+.view-mode-hint{margin-left:auto;color:var(--muted);font-size:.78rem}
+.view-mode-hint kbd,.home-quick-actions kbd{
+ font-family:monospace;
+ background:rgba(255,255,255,.06);
+ padding:1px 6px;
+ border-radius:4px;
+ font-size:.72rem;
+ border:1px solid rgba(255,255,255,.08);
+}
+@media (max-width: 560px){.view-mode-hint{display:none}}
+.home-quick-actions,
+.home-deck{
+ background:linear-gradient(135deg, rgba(122,143,231,.08), rgba(94,230,154,.04));
+ border:1px solid rgba(122,143,231,.18);
+}
+.home-quick-actions h2,
+.home-deck h2{margin:6px 0 10px}
+.home-action-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
+
+.home-scan-box{margin-top:12px}
+.home-side-cards{
+ margin-top:14px;
+ display:grid;
+ grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));
+ gap:10px;
+}
+.home-side-card{
+ padding:10px 14px;
+ background:rgba(0,0,0,.18);
+ border-radius:10px;
+ border:1px solid rgba(255,255,255,.04);
+}
diff --git a/brainsnn-r3f-app/src/utils/autoSteward.js b/brainsnn-r3f-app/src/utils/autoSteward.js
new file mode 100644
index 0000000..7a61bec
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/autoSteward.js
@@ -0,0 +1,223 @@
+/**
+ * Layer 103 — Auto-Apply Rule Steward
+ *
+ * Self-driving variant of Layer 21. Each cycle:
+ * 1. Pull a fresh harness report (Layer 102)
+ * 2. Pull a candidate rule diff (harnessProposer)
+ * 3. Filter additions to "low-risk" (≥ minLift, ≤ maxPerCycle, length floor)
+ * 4. Apply via Layer 55 customRules.addCustomRule()
+ * 5. Log the application to localStorage so reverts are possible
+ *
+ * Kill switches:
+ * - dryRun: true → log only, don't apply
+ * - paused: true → no-op every cycle
+ * - quotaPerHour: throttle so a runaway loop can't spam rules
+ *
+ * Followups[] from the proposer are surfaced as suggestions; this
+ * layer never auto-pauses the Steward or auto-runs evolve. Those
+ * are user calls.
+ */
+
+import { runDiagnostic } from './harnessFailureModes.js';
+import { proposeRuleDiff } from './harnessProposer.js';
+import { addCustomRule, removeCustomRule } from './customRules.js';
+import { recordSpan } from './telemetry.js';
+
+const APPLIED_KEY = 'brainsnn_auto_steward_applied_v1';
+const CONFIG_KEY = 'brainsnn_auto_steward_config_v1';
+const MAX_APPLIED = 80;
+
+const DEFAULT_CONFIG = {
+ enabled: false,
+ dryRun: true,
+ intervalMs: 60_000,
+ minLift: 4,
+ maxPerCycle: 2,
+ quotaPerHour: 6,
+ minPatternChars: 6,
+};
+
+let _state = {
+ running: false,
+ intervalId: null,
+ config: loadConfig(),
+ lastReport: null,
+ lastDiff: null,
+ cycleCount: 0,
+ subscribers: new Set(),
+};
+
+function loadConfig() {
+ try {
+ const raw = localStorage.getItem(CONFIG_KEY);
+ return raw ? { ...DEFAULT_CONFIG, ...JSON.parse(raw) } : { ...DEFAULT_CONFIG };
+ } catch { return { ...DEFAULT_CONFIG }; }
+}
+
+function persistConfig(cfg) {
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); } catch { /* noop */ }
+}
+
+export function getAppliedLog() {
+ try {
+ return JSON.parse(localStorage.getItem(APPLIED_KEY) || '[]');
+ } catch { return []; }
+}
+
+function pushApplied(entry) {
+ try {
+ const list = [entry, ...getAppliedLog()].slice(0, MAX_APPLIED);
+ localStorage.setItem(APPLIED_KEY, JSON.stringify(list));
+ } catch { /* noop */ }
+}
+
+function emit() {
+ for (const fn of _state.subscribers) {
+ try { fn(getStatus()); } catch { /* noop */ }
+ }
+}
+
+export function subscribe(fn) {
+ _state.subscribers.add(fn);
+ return () => _state.subscribers.delete(fn);
+}
+
+export function getStatus() {
+ return {
+ running: _state.running,
+ config: { ..._state.config },
+ cycleCount: _state.cycleCount,
+ appliedLog: getAppliedLog(),
+ lastReportTier: _state.lastReport?.tier || 'unknown',
+ lastDiffAdditions: _state.lastDiff?.additions?.length || 0,
+ lastDiffFollowUps: _state.lastDiff?.followUps || [],
+ };
+}
+
+export function updateConfig(partial) {
+ _state.config = { ..._state.config, ...partial };
+ persistConfig(_state.config);
+ if (_state.running && partial.intervalMs) {
+ stop();
+ start();
+ }
+ emit();
+}
+
+function recentlyAppliedCount(windowMs = 3_600_000) {
+ const cutoff = Date.now() - windowMs;
+ return getAppliedLog().filter((e) => e.ts >= cutoff && !e.dryRun && !e.reverted).length;
+}
+
+function isLowRisk(addition, cfg) {
+ if (!addition.pattern || addition.pattern.length < cfg.minPatternChars) return false;
+ // Must come from error-lift sources with explicit lift number
+ const m = /error-lift:([0-9.]+)/.exec(addition.source || '');
+ if (!m) return false;
+ const lift = Number(m[1]);
+ if (!Number.isFinite(lift) || lift < cfg.minLift) return false;
+ return true;
+}
+
+async function runCycle() {
+ _state.cycleCount += 1;
+ const cfg = _state.config;
+ if (!cfg.enabled) {
+ emit();
+ return;
+ }
+
+ const cycleStart = Date.now();
+ const report = runDiagnostic();
+ const diff = proposeRuleDiff(report, { topK: cfg.maxPerCycle * 3 });
+ _state.lastReport = report;
+ _state.lastDiff = diff;
+
+ const candidates = (diff.additions || []).filter((a) => isLowRisk(a, cfg));
+ const headroom = Math.max(0, cfg.quotaPerHour - recentlyAppliedCount());
+ const slice = candidates.slice(0, Math.min(cfg.maxPerCycle, headroom));
+
+ const applied = [];
+ for (const a of slice) {
+ const entry = {
+ ts: Date.now(),
+ cycle: _state.cycleCount,
+ category: a.category,
+ pattern: a.pattern,
+ label: a.label,
+ source: a.source,
+ dryRun: !!cfg.dryRun,
+ reverted: false,
+ };
+ if (cfg.dryRun) {
+ pushApplied(entry);
+ applied.push(entry);
+ continue;
+ }
+ try {
+ const created = addCustomRule({
+ category: a.category,
+ pattern: a.pattern,
+ flags: 'gi',
+ label: a.label || `auto-${a.category}`,
+ });
+ entry.ruleId = created.id;
+ pushApplied(entry);
+ applied.push(entry);
+ } catch (err) {
+ pushApplied({ ...entry, error: err.message || String(err) });
+ }
+ }
+
+ recordSpan({
+ name: 'auto-steward.cycle',
+ kind: 'internal',
+ start_time: cycleStart,
+ attributes: {
+ cycle: _state.cycleCount,
+ tier: report.tier,
+ candidates: candidates.length,
+ applied: applied.length,
+ dryRun: !!cfg.dryRun,
+ },
+ status: { code: 'ok' },
+ });
+
+ emit();
+}
+
+export function start() {
+ if (_state.running) return;
+ _state.running = true;
+ _state.intervalId = setInterval(runCycle, _state.config.intervalMs);
+ runCycle();
+ emit();
+}
+
+export function stop() {
+ if (!_state.running) return;
+ clearInterval(_state.intervalId);
+ _state.intervalId = null;
+ _state.running = false;
+ emit();
+}
+
+export function revertApplied(ruleId) {
+ if (!ruleId) return;
+ removeCustomRule(ruleId);
+ try {
+ const list = getAppliedLog().map((e) => e.ruleId === ruleId ? { ...e, reverted: true } : e);
+ localStorage.setItem(APPLIED_KEY, JSON.stringify(list));
+ } catch { /* noop */ }
+ emit();
+}
+
+export function clearAppliedLog() {
+ try { localStorage.removeItem(APPLIED_KEY); } catch { /* noop */ }
+ emit();
+}
+
+/** Manually run one cycle (without enabling the timer). */
+export async function runOnce() {
+ await runCycle();
+}
diff --git a/brainsnn-r3f-app/src/utils/brainSteward.js b/brainsnn-r3f-app/src/utils/brainSteward.js
index bb262ae..ceccf10 100644
--- a/brainsnn-r3f-app/src/utils/brainSteward.js
+++ b/brainsnn-r3f-app/src/utils/brainSteward.js
@@ -10,6 +10,7 @@
import { handleToolCall } from './mcpBridge';
import { recordEvent as recordImmunity, IMMUNITY_EVENTS } from './immunityScore';
+import { startSpan, addEvent, endSpan } from './telemetry.js';
export const DEFAULT_RULES = {
checkIntervalMs: 4000,
@@ -118,6 +119,11 @@ export function clearLog() {
async function runTick() {
stewardState.runCount++;
const rules = stewardState.rules;
+ // Layer 102 — every tick is a telemetry span; sub-actions become events
+ const span = startSpan('steward.tick', {
+ attributes: { runCount: stewardState.runCount, intervalMs: rules.checkIntervalMs },
+ });
+ let tickStatus = { code: 'ok' };
// 1. Check anomalies
try {
@@ -127,6 +133,7 @@ async function runTick() {
stewardState.lastAnomalies = anomalies;
const high = anomalies.filter((a) => Math.abs(a.zScore ?? 0) >= rules.anomalyThreshold);
if (high.length) {
+ addEvent(span, 'anomaly', { count: high.length, threshold: rules.anomalyThreshold });
pushAction(
ACTION_TYPES.ANOMALY_DETECTED,
`${high.length} region${high.length > 1 ? 's' : ''} firing >${rules.anomalyThreshold}σ`,
@@ -150,6 +157,8 @@ async function runTick() {
}
} catch (err) {
pushAction('error', `Anomaly check failed: ${err.message}`);
+ addEvent(span, 'error', { phase: 'anomaly', message: err.message });
+ tickStatus = { code: 'error', message: err.message };
}
// 2. Narrate + detect scenario change
@@ -174,6 +183,8 @@ async function runTick() {
}
} catch (err) {
pushAction('error', `Narration failed: ${err.message}`);
+ addEvent(span, 'error', { phase: 'narration', message: err.message });
+ tickStatus = { code: 'error', message: err.message };
}
}
@@ -193,5 +204,6 @@ async function runTick() {
}
} catch { /* ignore */ }
+ endSpan(span, { status: tickStatus });
emit();
}
diff --git a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js
index ac823b6..d048bcc 100644
--- a/brainsnn-r3f-app/src/utils/cognitiveFirewall.js
+++ b/brainsnn-r3f-app/src/utils/cognitiveFirewall.js
@@ -1,5 +1,6 @@
import { detectTemplates } from './propagandaTemplates.js';
import { detectLanguage, patternsFor, labelFor as languageLabel } from './firewallI18n.js';
+import { recordSpan } from './telemetry.js';
const URGENCY_PATTERNS = [
/\bnow\b|\bimmediately\b|\burgent\b|\bbreaking\b|\balert\b/gi,
@@ -169,6 +170,7 @@ export function resetActiveRules() {
}
export function scoreContent(text = '') {
+ const t0 = Date.now();
// Layer 52 — route to a language-specific pack when the active rules
// are the default English set. Manual promotion via setActiveRules
// always wins (user intent > auto-detection).
@@ -184,6 +186,23 @@ export function scoreContent(text = '') {
// Layer 52 — record which language pack was used
base.language = lang;
base.languageLabel = languageLabel(lang);
+
+ // Layer 102 — emit a telemetry span so the harness diagnostic can
+ // see scan volume, latency, and per-language outcome distribution.
+ recordSpan({
+ name: 'firewall.scan',
+ kind: 'internal',
+ start_time: t0,
+ attributes: {
+ lang,
+ ruleset: _activeRules === DEFAULT_RULES ? 'default' : 'custom',
+ pressure: Number((base.manipulationPressure || 0).toFixed(3)),
+ evidenceCount: (base.evidence || []).length,
+ templateCount: (base.templates || []).length,
+ textLength: (text || '').length,
+ },
+ });
+
return base;
}
diff --git a/brainsnn-r3f-app/src/utils/contentProvenance.js b/brainsnn-r3f-app/src/utils/contentProvenance.js
new file mode 100644
index 0000000..7a8043a
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/contentProvenance.js
@@ -0,0 +1,497 @@
+/**
+ * Layer 101 — Content Verification System
+ *
+ * "We're shifting from 'what we see is real' to 'what's the chain of
+ * custody on what we see'." This layer answers two questions:
+ *
+ * 1. Who signed this content, and has it been tampered with?
+ * → ECDSA P-256 keypair via Web Crypto, persisted locally.
+ * → Signed manifests carry a SHA-256 of the payload + ordered
+ * edit log. Each edit is itself signed and references the
+ * previous step's hash, forming a chain of custody (lite C2PA).
+ *
+ * 2. Does this read as human or AI-perfect?
+ * → Heuristic humanity score: candid markers (typos, hedges,
+ * ellipses, ALL-CAPS bursts, em-dashes used naturally) versus
+ * AI-perfect markers (uniform sentence cadence, hedging
+ * boilerplate, listicle scaffolding, no contractions, etc).
+ * → Returns a 0–100 score + per-axis evidence so creators can
+ * see WHY a piece reads human or polished.
+ *
+ * All keys, manifests, and the rolling log live in localStorage.
+ * Falls back gracefully when SubtleCrypto is unavailable (FNV hash
+ * receipt only, signing disabled with a clear status).
+ */
+
+const PRIVKEY_KEY = 'brainsnn_provenance_priv_v1';
+const PUBKEY_KEY = 'brainsnn_provenance_pub_v1';
+const HANDLE_KEY = 'brainsnn_provenance_handle_v1';
+const LOG_KEY = 'brainsnn_provenance_log_v1';
+const MAX_LOG = 25;
+
+const SIGN_ALG = { name: 'ECDSA', namedCurve: 'P-256' };
+const SIGN_PARAMS = { name: 'ECDSA', hash: { name: 'SHA-256' } };
+
+/* ----------------------------- crypto core ---------------------------- */
+
+export function isCryptoAvailable() {
+ return (
+ typeof crypto !== 'undefined'
+ && !!crypto.subtle
+ && typeof crypto.subtle.generateKey === 'function'
+ && typeof crypto.subtle.sign === 'function'
+ );
+}
+
+function bufToB64(buf) {
+ const bytes = new Uint8Array(buf);
+ let bin = '';
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
+ return btoa(bin);
+}
+
+function b64ToBuf(b64) {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out.buffer;
+}
+
+async function sha256Hex(str) {
+ if (isCryptoAvailable()) {
+ const buf = new TextEncoder().encode(str);
+ const digest = await crypto.subtle.digest('SHA-256', buf);
+ return Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+ }
+ // FNV-1a fallback (display-only; not cryptographically secure)
+ let h1 = 0xdeadbeef ^ str.length;
+ let h2 = 0x41c6ce57 ^ str.length;
+ for (let i = 0; i < str.length; i++) {
+ const ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+ return (
+ (h1 >>> 0).toString(16).padStart(8, '0')
+ + (h2 >>> 0).toString(16).padStart(8, '0')
+ );
+}
+
+/* ------------------------------ identity ------------------------------ */
+
+export async function ensureIdentity({ handle } = {}) {
+ if (!isCryptoAvailable()) {
+ return { ok: false, reason: 'subtle-unavailable' };
+ }
+ const existing = loadIdentity();
+ if (existing.priv && existing.pub) return { ok: true, identity: existing };
+
+ const pair = await crypto.subtle.generateKey(SIGN_ALG, true, ['sign', 'verify']);
+ const privJwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
+ const pubJwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
+ const pubFp = await sha256Hex(JSON.stringify(pubJwk));
+
+ try {
+ localStorage.setItem(PRIVKEY_KEY, JSON.stringify(privJwk));
+ localStorage.setItem(PUBKEY_KEY, JSON.stringify(pubJwk));
+ if (handle) localStorage.setItem(HANDLE_KEY, String(handle).slice(0, 40));
+ } catch { /* quota — caller will retry */ }
+
+ return {
+ ok: true,
+ identity: {
+ priv: privJwk,
+ pub: pubJwk,
+ fingerprint: pubFp,
+ handle: handle || loadHandle(),
+ },
+ };
+}
+
+export function loadIdentity() {
+ try {
+ const priv = JSON.parse(localStorage.getItem(PRIVKEY_KEY) || 'null');
+ const pub = JSON.parse(localStorage.getItem(PUBKEY_KEY) || 'null');
+ return { priv, pub, handle: loadHandle() };
+ } catch { return { priv: null, pub: null, handle: '' }; }
+}
+
+export function loadHandle() {
+ try { return localStorage.getItem(HANDLE_KEY) || ''; } catch { return ''; }
+}
+
+export function setHandle(h) {
+ try { localStorage.setItem(HANDLE_KEY, String(h || '').slice(0, 40)); } catch { /* noop */ }
+}
+
+export async function fingerprintFor(pubJwk) {
+ return sha256Hex(JSON.stringify(pubJwk));
+}
+
+export function clearIdentity() {
+ try {
+ localStorage.removeItem(PRIVKEY_KEY);
+ localStorage.removeItem(PUBKEY_KEY);
+ } catch { /* noop */ }
+}
+
+async function importPriv(jwk) {
+ return crypto.subtle.importKey('jwk', jwk, SIGN_ALG, false, ['sign']);
+}
+
+async function importPub(jwk) {
+ return crypto.subtle.importKey('jwk', jwk, SIGN_ALG, true, ['verify']);
+}
+
+/* ----------------------------- manifests ------------------------------ */
+
+function canonicalize(obj) {
+ // Stable JSON: sort keys recursively so signatures are reproducible.
+ if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
+ if (Array.isArray(obj)) return '[' + obj.map(canonicalize).join(',') + ']';
+ const keys = Object.keys(obj).sort();
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalize(obj[k])).join(',') + '}';
+}
+
+function nowIso() { return new Date().toISOString(); }
+
+/**
+ * Sign content. Returns a manifest object suitable for sharing alongside
+ * the original payload. Recipients only need the manifest + payload to
+ * verify (manifest carries the public key + chain).
+ */
+export async function signContent({
+ payload,
+ payloadKind = 'text',
+ capturedAt,
+ device,
+ location,
+ note,
+}) {
+ if (!isCryptoAvailable()) {
+ return { ok: false, reason: 'subtle-unavailable' };
+ }
+ const id = await ensureIdentity();
+ if (!id.ok) return { ok: false, reason: id.reason };
+ const { priv, pub, handle } = id.identity;
+
+ const payloadHash = await sha256Hex(String(payload || ''));
+ const fingerprint = await fingerprintFor(pub);
+ const step = {
+ kind: 'origin',
+ payloadKind,
+ payloadHash,
+ capturedAt: capturedAt || nowIso(),
+ device: (device || '').slice(0, 80),
+ location: (location || '').slice(0, 80),
+ note: (note || '').slice(0, 200),
+ prevHash: null,
+ };
+ const stepCanonical = canonicalize(step);
+ const stepHash = await sha256Hex(stepCanonical);
+
+ const privKey = await importPriv(priv);
+ const sigBuf = await crypto.subtle.sign(
+ SIGN_PARAMS,
+ privKey,
+ new TextEncoder().encode(stepCanonical),
+ );
+ const sig = bufToB64(sigBuf);
+
+ const manifest = {
+ v: 'brainsnn-prov/1',
+ handle: handle || '',
+ pub,
+ fingerprint,
+ chain: [{ ...step, hash: stepHash, sig }],
+ };
+ return { ok: true, manifest, payloadHash };
+}
+
+/**
+ * Append a signed edit step to an existing manifest. Use this when the
+ * creator legitimately edits the post — the chain shows reviewers the
+ * full history with hash-linked signatures.
+ */
+export async function appendEdit({ manifest, payload, note, kind = 'edit' }) {
+ if (!isCryptoAvailable()) return { ok: false, reason: 'subtle-unavailable' };
+ if (!manifest || !Array.isArray(manifest.chain) || manifest.chain.length === 0) {
+ return { ok: false, reason: 'invalid-manifest' };
+ }
+ const id = loadIdentity();
+ if (!id.priv) return { ok: false, reason: 'no-identity' };
+
+ const last = manifest.chain[manifest.chain.length - 1];
+ const payloadHash = await sha256Hex(String(payload || ''));
+ const step = {
+ kind,
+ payloadKind: last.payloadKind,
+ payloadHash,
+ capturedAt: nowIso(),
+ device: '',
+ location: '',
+ note: (note || '').slice(0, 200),
+ prevHash: last.hash,
+ };
+ const stepCanonical = canonicalize(step);
+ const stepHash = await sha256Hex(stepCanonical);
+ const privKey = await importPriv(id.priv);
+ const sigBuf = await crypto.subtle.sign(
+ SIGN_PARAMS,
+ privKey,
+ new TextEncoder().encode(stepCanonical),
+ );
+ const sig = bufToB64(sigBuf);
+
+ const next = {
+ ...manifest,
+ chain: [...manifest.chain, { ...step, hash: stepHash, sig }],
+ };
+ return { ok: true, manifest: next, payloadHash };
+}
+
+/**
+ * Verify a manifest against a payload. Walks the chain, re-derives each
+ * step's hash, checks signature against manifest.pub, confirms the
+ * latest step's payloadHash matches the supplied payload, and that
+ * prevHash links are intact.
+ */
+export async function verifyManifest({ manifest, payload }) {
+ if (!isCryptoAvailable()) return { ok: false, reason: 'subtle-unavailable' };
+ if (!manifest || manifest.v !== 'brainsnn-prov/1') {
+ return { ok: false, reason: 'unknown-manifest' };
+ }
+ if (!manifest.pub || !Array.isArray(manifest.chain) || manifest.chain.length === 0) {
+ return { ok: false, reason: 'invalid-manifest' };
+ }
+
+ const pubKey = await importPub(manifest.pub);
+ const fingerprint = await fingerprintFor(manifest.pub);
+ const steps = [];
+ let prevHash = null;
+ for (const entry of manifest.chain) {
+ const { hash, sig, ...step } = entry;
+ if (step.prevHash !== prevHash) {
+ return {
+ ok: false,
+ reason: 'chain-broken',
+ atIndex: steps.length,
+ fingerprint,
+ };
+ }
+ const stepCanonical = canonicalize(step);
+ const reHash = await sha256Hex(stepCanonical);
+ if (reHash !== hash) {
+ return { ok: false, reason: 'hash-mismatch', atIndex: steps.length, fingerprint };
+ }
+ let sigOk = false;
+ try {
+ sigOk = await crypto.subtle.verify(
+ SIGN_PARAMS,
+ pubKey,
+ b64ToBuf(sig),
+ new TextEncoder().encode(stepCanonical),
+ );
+ } catch { sigOk = false; }
+ if (!sigOk) {
+ return { ok: false, reason: 'bad-signature', atIndex: steps.length, fingerprint };
+ }
+ steps.push(entry);
+ prevHash = hash;
+ }
+
+ const last = steps[steps.length - 1];
+ const payloadHash = await sha256Hex(String(payload || ''));
+ const payloadOk = last.payloadHash === payloadHash;
+
+ return {
+ ok: payloadOk,
+ reason: payloadOk ? 'verified' : 'payload-tampered',
+ fingerprint,
+ handle: manifest.handle || '',
+ steps,
+ payloadHash,
+ expectedPayloadHash: last.payloadHash,
+ };
+}
+
+/* ------------------------------- log ---------------------------------- */
+
+export function recentManifestLog() {
+ try {
+ const raw = localStorage.getItem(LOG_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch { return []; }
+}
+
+export function logManifest({ manifest, payloadExcerpt }) {
+ try {
+ const list = recentManifestLog();
+ const last = manifest.chain[manifest.chain.length - 1];
+ const slim = {
+ ts: Date.now(),
+ handle: manifest.handle || '',
+ fingerprint: manifest.fingerprint,
+ payloadKind: last.payloadKind,
+ payloadHash: last.payloadHash,
+ stepCount: manifest.chain.length,
+ excerpt: (payloadExcerpt || '').slice(0, 80),
+ latestSig: last.sig.slice(0, 16),
+ };
+ const next = [slim, ...list.filter((x) => x.payloadHash !== slim.payloadHash)].slice(0, MAX_LOG);
+ localStorage.setItem(LOG_KEY, JSON.stringify(next));
+ return next;
+ } catch { return recentManifestLog(); }
+}
+
+export function clearManifestLog() {
+ try { localStorage.removeItem(LOG_KEY); } catch { /* noop */ }
+}
+
+/* ---------------------------- humanity score -------------------------- */
+
+const AI_BOILERPLATE = [
+ /\bin (?:today's|todays) (?:fast[- ]paced|digital|modern) world\b/i,
+ /\bit (?:is|'s) important to (?:note|remember|consider)\b/i,
+ /\bin conclusion,?\b/i,
+ /\bdelve into\b/i,
+ /\bfurthermore,?\b/i,
+ /\bmoreover,?\b/i,
+ /\badditionally,?\b/i,
+ /\boverall,?\s+this\b/i,
+ /\bnavigat(?:e|ing) the (?:complex|landscape|world)\b/i,
+ /\b(?:tapestry|ever[- ]evolving|cutting[- ]edge|game[- ]changer)\b/i,
+ /\b(?:unleash|harness|leverage|elevate) (?:your|the) (?:potential|power)\b/i,
+ /\bas an ai (?:language )?model\b/i,
+];
+
+const HEDGE_HUMAN = /\b(kinda|sorta|maybe|honestly|tbh|idk|imo|fwiw|ish|ok so)\b/i;
+const FILLER = /\b(uh|um|like,|you know|i mean|anyway,|right\?)\b/i;
+const CANDID = /(\.\.\.|—| -- |\bbtw\b|\blol\b|\boof\b|\byikes\b|\bwtf\b|\bdamn\b)/i;
+const CONTRACTIONS = /\b(don't|can't|won't|i'm|you're|it's|that's|we're|they're|isn't|aren't|wasn't|weren't|i'll|we'll)\b/i;
+
+function tokenizeSentences(text) {
+ return String(text || '')
+ .replace(/\s+/g, ' ')
+ .split(/(?<=[.!?])\s+(?=[A-Z“"'])/)
+ .filter(Boolean);
+}
+
+function tokenizeWords(text) {
+ return String(text || '').toLowerCase().match(/[a-z']+/g) || [];
+}
+
+function stddev(arr) {
+ if (!arr.length) return 0;
+ const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
+ const v = arr.reduce((a, b) => a + (b - mean) ** 2, 0) / arr.length;
+ return Math.sqrt(v);
+}
+
+function clamp01(n) { return Math.max(0, Math.min(1, n)); }
+
+/**
+ * Heuristic humanity score 0–100. Higher = more human-feeling.
+ * Returns score + per-axis evidence so the user can see WHY.
+ */
+export function humanityScore(text) {
+ const t = String(text || '');
+ const sentences = tokenizeSentences(t);
+ const words = tokenizeWords(t);
+ if (words.length < 6) {
+ return {
+ score: 50,
+ tier: 'undecidable',
+ verdict: 'Too short to score',
+ evidence: [],
+ axes: {},
+ };
+ }
+
+ const lengths = sentences.map((s) => tokenizeWords(s).length);
+ const cadenceStd = stddev(lengths);
+ const cadenceScore = clamp01(cadenceStd / 8); // human prose varies; AI is uniform
+
+ const aiHits = AI_BOILERPLATE.reduce((acc, rx) => acc + (rx.test(t) ? 1 : 0), 0);
+ const aiScore = clamp01(aiHits / 3);
+
+ const candidHits = (
+ (HEDGE_HUMAN.test(t) ? 1 : 0)
+ + (FILLER.test(t) ? 1 : 0)
+ + (CANDID.test(t) ? 1 : 0)
+ );
+ const candidScore = clamp01(candidHits / 3);
+
+ const contractionHits = (t.match(CONTRACTIONS) || []).length;
+ const contractionScore = clamp01(contractionHits / 4);
+
+ // Typo / casing irregularity proxy: lowercase-start sentences,
+ // double spaces, repeated punctuation
+ const lowerStarts = sentences.filter((s) => /^[a-z]/.test(s.trim())).length;
+ const repeatPunct = (t.match(/!!|\?\?|\.{3,}/g) || []).length;
+ const irregularityScore = clamp01((lowerStarts + repeatPunct) / 4);
+
+ const human = (
+ 0.30 * cadenceScore
+ + 0.25 * candidScore
+ + 0.20 * contractionScore
+ + 0.15 * irregularityScore
+ + 0.10 * (1 - aiScore) // boilerplate suppresses humanity
+ );
+ const polished = (
+ 0.55 * aiScore
+ + 0.20 * (1 - cadenceScore)
+ + 0.15 * (1 - candidScore)
+ + 0.10 * (1 - contractionScore)
+ );
+
+ const raw = 0.5 + (human - polished) * 0.6;
+ const score = Math.round(clamp01(raw) * 100);
+
+ const tier = (
+ score >= 80 ? 'unmistakably human'
+ : score >= 65 ? 'reads human'
+ : score >= 45 ? 'mixed signals'
+ : score >= 25 ? 'leans polished'
+ : 'AI-perfect'
+ );
+
+ const evidence = [];
+ if (aiHits > 0) evidence.push({ axis: 'boilerplate', hits: aiHits, note: 'AI-flavored phrasing detected' });
+ if (candidHits > 0) evidence.push({ axis: 'candid', hits: candidHits, note: 'hedges / fillers / asides' });
+ if (contractionHits > 0) evidence.push({ axis: 'contractions', hits: contractionHits, note: 'casual contractions' });
+ if (cadenceStd >= 5) evidence.push({ axis: 'cadence', hits: Math.round(cadenceStd), note: 'varied sentence length' });
+ if (lowerStarts + repeatPunct > 0) {
+ evidence.push({ axis: 'irregularities', hits: lowerStarts + repeatPunct, note: 'lowercase starts / repeated punct' });
+ }
+
+ return {
+ score,
+ tier,
+ verdict: tier,
+ evidence,
+ axes: {
+ cadence: Math.round(cadenceScore * 100),
+ candid: Math.round(candidScore * 100),
+ contractions: Math.round(contractionScore * 100),
+ irregularity: Math.round(irregularityScore * 100),
+ boilerplate: Math.round(aiScore * 100),
+ },
+ };
+}
+
+/* ---------------------------- share helpers --------------------------- */
+
+export function manifestToShareString(manifest) {
+ return JSON.stringify(manifest);
+}
+
+export function tryParseManifest(str) {
+ try {
+ const obj = JSON.parse(String(str || ''));
+ if (obj && obj.v === 'brainsnn-prov/1') return obj;
+ } catch { /* noop */ }
+ return null;
+}
diff --git a/brainsnn-r3f-app/src/utils/diagnosticSnapshots.js b/brainsnn-r3f-app/src/utils/diagnosticSnapshots.js
new file mode 100644
index 0000000..9f4c90a
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/diagnosticSnapshots.js
@@ -0,0 +1,129 @@
+/**
+ * Layer 113 — Diagnostic Snapshots
+ *
+ * Background watcher that auto-saves a Layer 104 snapshot whenever
+ * the harness tier changes. Builds a longitudinal timeline of every
+ * tier shift so you can answer "when did we go critical?" without
+ * manual snapshotting.
+ *
+ * Stays separate from Layer 104's manual snapshots — auto-snaps are
+ * tagged so they can be filtered or pruned independently.
+ */
+
+import { subscribe as subSpans } from './telemetry.js';
+import { runDiagnostic } from './harnessFailureModes.js';
+import { saveSnapshot, listSnapshots, deleteSnapshot } from './harnessComparator.js';
+
+const CONFIG_KEY = 'brainsnn_diag_snapshots_config_v1';
+const TIMELINE_KEY = 'brainsnn_diag_snapshots_timeline_v1';
+const MAX_TIMELINE = 50;
+
+const DEFAULT_CONFIG = {
+ enabled: false,
+ debounceMs: 5000,
+};
+
+let _state = {
+ active: false,
+ unsub: null,
+ pending: false,
+ timer: null,
+ lastTier: null,
+ config: loadConfig(),
+};
+
+export function loadConfig() {
+ try {
+ const raw = localStorage.getItem(CONFIG_KEY);
+ return raw ? { ...DEFAULT_CONFIG, ...JSON.parse(raw) } : { ...DEFAULT_CONFIG };
+ } catch { return { ...DEFAULT_CONFIG }; }
+}
+
+export function saveConfig(partial) {
+ _state.config = { ..._state.config, ...partial };
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify(_state.config)); } catch { /* noop */ }
+ return _state.config;
+}
+
+export function timeline() {
+ try { return JSON.parse(localStorage.getItem(TIMELINE_KEY) || '[]'); } catch { return []; }
+}
+
+function pushTimeline(entry) {
+ try {
+ const next = [entry, ...timeline()].slice(0, MAX_TIMELINE);
+ localStorage.setItem(TIMELINE_KEY, JSON.stringify(next));
+ } catch { /* noop */ }
+}
+
+export function clearTimeline() {
+ try { localStorage.removeItem(TIMELINE_KEY); } catch { /* noop */ }
+}
+
+function maybeSnapshot() {
+ const report = runDiagnostic();
+ if (_state.lastTier === null) {
+ _state.lastTier = report.tier;
+ return;
+ }
+ if (report.tier === _state.lastTier) return;
+ const entry = saveSnapshot({
+ label: `auto · ${_state.lastTier}→${report.tier}`,
+ report,
+ });
+ pushTimeline({
+ ts: Date.now(),
+ snapshotId: entry.id,
+ fromTier: _state.lastTier,
+ toTier: report.tier,
+ findingIds: report.findings.map((f) => f.id),
+ });
+ _state.lastTier = report.tier;
+}
+
+function onSpanEvent() {
+ if (!_state.active || !_state.config.enabled) return;
+ if (_state.pending) return;
+ _state.pending = true;
+ _state.timer = setTimeout(() => {
+ _state.pending = false;
+ try { maybeSnapshot(); } catch { /* noop */ }
+ }, _state.config.debounceMs);
+}
+
+export function start() {
+ if (_state.active) return;
+ _state.active = true;
+ // Prime baseline
+ try { _state.lastTier = runDiagnostic().tier; } catch { _state.lastTier = null; }
+ _state.unsub = subSpans(onSpanEvent);
+}
+
+export function stop() {
+ if (!_state.active) return;
+ if (_state.unsub) _state.unsub();
+ _state.unsub = null;
+ if (_state.timer) clearTimeout(_state.timer);
+ _state.timer = null;
+ _state.pending = false;
+ _state.active = false;
+}
+
+export function isActive() {
+ return _state.active;
+}
+
+/**
+ * Prune auto-tagged snapshots only, leave manual snapshots intact.
+ */
+export function pruneAuto() {
+ const ids = new Set(timeline().map((t) => t.snapshotId));
+ const remaining = listSnapshots().filter((s) => !ids.has(s.id) || !/^auto · /.test(s.label));
+ for (const s of listSnapshots()) {
+ if (ids.has(s.id) && /^auto · /.test(s.label)) {
+ deleteSnapshot(s.id);
+ }
+ }
+ clearTimeline();
+ return remaining;
+}
diff --git a/brainsnn-r3f-app/src/utils/harnessAlerts.js b/brainsnn-r3f-app/src/utils/harnessAlerts.js
new file mode 100644
index 0000000..d5db18d
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/harnessAlerts.js
@@ -0,0 +1,119 @@
+/**
+ * Layer 109 — Harness Alerts
+ *
+ * Subscribe to the live span buffer, run the diagnostic on every
+ * burst of new activity, and toast when a finding fires that wasn't
+ * present before — or when the tier shifts upward.
+ *
+ * Pure-side-effect layer: orchestrates Layer 102 + Layer 9 (toasts).
+ * Operator-controlled — start() arms it, stop() disarms.
+ */
+
+import { subscribe as subSpans } from './telemetry.js';
+import { runDiagnostic } from './harnessFailureModes.js';
+import { toastWarning, toastError, toastInfo } from './toastStore.js';
+
+const CONFIG_KEY = 'brainsnn_alerts_config_v1';
+const DEFAULT_CONFIG = {
+ enabled: false,
+ debounceMs: 4000,
+ toastOnNewFinding: true,
+ toastOnTierShift: true,
+};
+
+const TIER_RANK = { healthy: 0, warn: 1, critical: 2 };
+
+let _state = {
+ active: false,
+ unsub: null,
+ pending: false,
+ timer: null,
+ lastTier: null,
+ lastFindingIds: new Set(),
+ config: loadConfig(),
+};
+
+export function loadConfig() {
+ try {
+ const raw = localStorage.getItem(CONFIG_KEY);
+ return raw ? { ...DEFAULT_CONFIG, ...JSON.parse(raw) } : { ...DEFAULT_CONFIG };
+ } catch { return { ...DEFAULT_CONFIG }; }
+}
+
+export function saveConfig(partial) {
+ _state.config = { ..._state.config, ...partial };
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify(_state.config)); } catch { /* noop */ }
+ return _state.config;
+}
+
+function check() {
+ const report = runDiagnostic();
+ const ids = new Set(report.findings.map((f) => f.id));
+ const newIds = [...ids].filter((id) => !_state.lastFindingIds.has(id));
+
+ if (_state.config.toastOnNewFinding) {
+ for (const id of newIds) {
+ const f = report.findings.find((x) => x.id === id);
+ if (!f) continue;
+ const msg = `[harness] ${f.label} ×${f.count}`;
+ if (f.severity === 'critical') toastError(msg);
+ else if (f.severity === 'warn') toastWarning(msg);
+ else toastInfo(msg);
+ }
+ }
+
+ if (_state.config.toastOnTierShift && _state.lastTier && _state.lastTier !== report.tier) {
+ const fromR = TIER_RANK[_state.lastTier] ?? 0;
+ const toR = TIER_RANK[report.tier] ?? 0;
+ if (toR > fromR) {
+ toastError(`Harness tier ${_state.lastTier} → ${report.tier}`);
+ } else if (toR < fromR) {
+ toastInfo(`Harness recovered: ${_state.lastTier} → ${report.tier}`);
+ }
+ }
+
+ _state.lastTier = report.tier;
+ _state.lastFindingIds = ids;
+ return report;
+}
+
+function onSpanEvent() {
+ if (!_state.active || !_state.config.enabled) return;
+ if (_state.pending) return;
+ _state.pending = true;
+ _state.timer = setTimeout(() => {
+ _state.pending = false;
+ try { check(); } catch { /* noop */ }
+ }, _state.config.debounceMs);
+}
+
+export function start() {
+ if (_state.active) return;
+ _state.active = true;
+ // Prime baseline so the first toast doesn't fire on existing findings
+ try { check(); } catch { /* noop */ }
+ _state.unsub = subSpans(onSpanEvent);
+}
+
+export function stop() {
+ if (!_state.active) return;
+ if (_state.unsub) _state.unsub();
+ _state.unsub = null;
+ if (_state.timer) clearTimeout(_state.timer);
+ _state.timer = null;
+ _state.pending = false;
+ _state.active = false;
+}
+
+export function isActive() {
+ return _state.active;
+}
+
+export function getStatus() {
+ return {
+ active: _state.active,
+ config: { ..._state.config },
+ lastTier: _state.lastTier,
+ lastFindings: [..._state.lastFindingIds],
+ };
+}
diff --git a/brainsnn-r3f-app/src/utils/harnessComparator.js b/brainsnn-r3f-app/src/utils/harnessComparator.js
new file mode 100644
index 0000000..27a42e7
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/harnessComparator.js
@@ -0,0 +1,168 @@
+/**
+ * Layer 104 — Harness Comparator
+ *
+ * "Did my rule change actually help?" — diff two harness-report-v1
+ * envelopes (typically a baseline snapshot + the latest report) and
+ * surface the deltas a user or coding agent can act on.
+ *
+ * - finding deltas: which findings appeared / disappeared / shifted
+ * severity
+ * - aggregate deltas: per-op p50 / error-rate change
+ * - tier transition: healthy → warn → critical and reverse
+ *
+ * Pure function. Pair with snapshotReport() to capture before/after
+ * fixtures around any change.
+ */
+
+const TIER_RANK = { healthy: 0, warn: 1, critical: 2, unknown: 0 };
+const SEV_RANK = { info: 0, warn: 1, critical: 2 };
+
+function index(arr, key) {
+ const m = new Map();
+ for (const item of arr || []) m.set(item[key], item);
+ return m;
+}
+
+export function compareReports(baseline, current) {
+ if (!baseline || baseline.brainsnn !== 'harness-report-v1'
+ || !current || current.brainsnn !== 'harness-report-v1') {
+ return {
+ brainsnn: 'harness-diff-v1',
+ generatedAt: new Date().toISOString(),
+ ok: false,
+ reason: 'invalid-input',
+ };
+ }
+
+ const tierShift = TIER_RANK[current.tier] - TIER_RANK[baseline.tier];
+ const tierLabel = (
+ tierShift < 0 ? 'improved'
+ : tierShift > 0 ? 'regressed'
+ : 'unchanged'
+ );
+
+ // Findings
+ const baseF = index(baseline.findings, 'id');
+ const currF = index(current.findings, 'id');
+ const findings = { added: [], removed: [], shifted: [] };
+ for (const [id, f] of currF) {
+ if (!baseF.has(id)) {
+ findings.added.push(f);
+ } else {
+ const prev = baseF.get(id);
+ if (SEV_RANK[f.severity] !== SEV_RANK[prev.severity] || f.count !== prev.count) {
+ findings.shifted.push({
+ id,
+ label: f.label,
+ severity: { from: prev.severity, to: f.severity },
+ count: { from: prev.count, to: f.count },
+ });
+ }
+ }
+ }
+ for (const [id, f] of baseF) {
+ if (!currF.has(id)) findings.removed.push(f);
+ }
+
+ // Aggregates
+ const baseA = index(baseline.aggregates || [], 'name');
+ const currA = index(current.aggregates || [], 'name');
+ const aggregates = [];
+ const allNames = new Set([...baseA.keys(), ...currA.keys()]);
+ for (const name of allNames) {
+ const a = baseA.get(name);
+ const b = currA.get(name);
+ if (!a) {
+ aggregates.push({ name, status: 'new', after: b });
+ } else if (!b) {
+ aggregates.push({ name, status: 'gone', before: a });
+ } else {
+ aggregates.push({
+ name,
+ status: 'present',
+ countDelta: b.count - a.count,
+ p50Delta: b.p50 - a.p50,
+ errorRateDelta: Number((b.errorRate - a.errorRate).toFixed(3)),
+ });
+ }
+ }
+ aggregates.sort((x, y) => Math.abs((y.errorRateDelta ?? 0)) - Math.abs((x.errorRateDelta ?? 0)));
+
+ // Totals
+ const totals = {
+ spans: { from: baseline.totals.spans, to: current.totals.spans, delta: current.totals.spans - baseline.totals.spans },
+ errors: { from: baseline.totals.errors, to: current.totals.errors, delta: current.totals.errors - baseline.totals.errors },
+ };
+
+ return {
+ brainsnn: 'harness-diff-v1',
+ generatedAt: new Date().toISOString(),
+ ok: true,
+ tier: { from: baseline.tier, to: current.tier, shift: tierLabel },
+ totals,
+ findings,
+ aggregates: aggregates.slice(0, 12),
+ };
+}
+
+export function renderDiffText(diff) {
+ if (!diff || !diff.ok) return diff?.reason || 'invalid diff';
+ const lines = [];
+ lines.push(`# Harness diff — ${diff.tier.from} → ${diff.tier.to} (${diff.tier.shift.toUpperCase()})`);
+ lines.push(`Spans Δ${diff.totals.spans.delta} · Errors Δ${diff.totals.errors.delta}`);
+ if (diff.findings.added.length) {
+ lines.push('');
+ lines.push('## New findings');
+ for (const f of diff.findings.added) lines.push(`+ [${f.severity}] ${f.label} ×${f.count}`);
+ }
+ if (diff.findings.removed.length) {
+ lines.push('');
+ lines.push('## Resolved findings');
+ for (const f of diff.findings.removed) lines.push(`- [${f.severity}] ${f.label} ×${f.count}`);
+ }
+ if (diff.findings.shifted.length) {
+ lines.push('');
+ lines.push('## Severity / count changes');
+ for (const f of diff.findings.shifted) {
+ lines.push(`~ ${f.label} · ${f.severity.from}→${f.severity.to} · ×${f.count.from}→×${f.count.to}`);
+ }
+ }
+ return lines.join('\n');
+}
+
+/* ---------------------------- snapshots ----------------------------- */
+
+const SNAPSHOTS_KEY = 'brainsnn_harness_snapshots_v1';
+const MAX_SNAPSHOTS = 20;
+
+export function listSnapshots() {
+ try {
+ return JSON.parse(localStorage.getItem(SNAPSHOTS_KEY) || '[]');
+ } catch { return []; }
+}
+
+export function saveSnapshot({ label, report }) {
+ if (!report || report.brainsnn !== 'harness-report-v1') {
+ throw new Error('Need a harness-report-v1 to snapshot');
+ }
+ const list = listSnapshots();
+ const entry = {
+ id: `hs_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
+ ts: Date.now(),
+ label: (label || `snap-${list.length + 1}`).slice(0, 40),
+ report,
+ };
+ const next = [entry, ...list].slice(0, MAX_SNAPSHOTS);
+ try { localStorage.setItem(SNAPSHOTS_KEY, JSON.stringify(next)); } catch { /* noop */ }
+ return entry;
+}
+
+export function deleteSnapshot(id) {
+ const next = listSnapshots().filter((s) => s.id !== id);
+ try { localStorage.setItem(SNAPSHOTS_KEY, JSON.stringify(next)); } catch { /* noop */ }
+ return next;
+}
+
+export function clearSnapshots() {
+ try { localStorage.removeItem(SNAPSHOTS_KEY); } catch { /* noop */ }
+}
diff --git a/brainsnn-r3f-app/src/utils/harnessFailureModes.js b/brainsnn-r3f-app/src/utils/harnessFailureModes.js
new file mode 100644
index 0000000..b4aeffc
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/harnessFailureModes.js
@@ -0,0 +1,256 @@
+/**
+ * Layer 102 — Failure-mode taxonomy
+ *
+ * HALO ships a named list of failure modes its analyzer looks for —
+ * "hallucinated tool calls", "redundant args", "refusal loops". Each
+ * mode is a pattern the harness keeps falling into. The Brain has its
+ * own failure modes; this module codifies them as detectors that run
+ * over the unified telemetry span buffer.
+ *
+ * Each detector returns:
+ * { id, label, severity, count, examples: [span], hint? }
+ *
+ * `hint` is an actionable nudge the report writer (or a coding agent
+ * via Layer 19 MCP) can act on without further context.
+ *
+ * Severity tiers: 'info' < 'warn' < 'critical'.
+ */
+
+import { aggregateByName, recentSpans } from './telemetry.js';
+import { mineSpansByOutcome } from './harnessLift.js';
+import { decorateSpansWithAnnotations } from './spanAnnotation.js';
+
+const SLOW_SCAN_MS = 250;
+const HUNG_MCP_MS = 4000;
+
+function pickExamples(spans, n = 3) {
+ return spans.slice(0, n).map((s) => ({
+ name: s.name,
+ span_id: s.span_id,
+ duration_ms: s.duration_ms,
+ status: s.status?.code,
+ attributes: s.attributes,
+ end_time: s.end_time,
+ }));
+}
+
+/* ------------------------------ detectors ----------------------------- */
+
+export function detectErrorBursts(spans) {
+ const errs = spans.filter((s) => s.status?.code === 'error');
+ if (errs.length < 3) return null;
+ // burst = >=3 errors within a 60-second window
+ const sorted = errs.slice().sort((a, b) => (a.end_time || 0) - (b.end_time || 0));
+ let bursts = 0;
+ for (let i = 0; i < sorted.length - 2; i++) {
+ if ((sorted[i + 2].end_time - sorted[i].end_time) <= 60_000) bursts += 1;
+ }
+ if (bursts === 0) return null;
+ return {
+ id: 'error-burst',
+ label: 'Error burst',
+ severity: 'critical',
+ count: errs.length,
+ examples: pickExamples(sorted, 3),
+ hint: 'Multiple errors within a 60-second window. Inspect the most recent span attributes — likely a flapping integration or expired credential.',
+ };
+}
+
+export function detectSlowScans(spans) {
+ const slow = spans.filter((s) => s.name === 'firewall.scan' && (s.duration_ms || 0) > SLOW_SCAN_MS);
+ if (slow.length < 3) return null;
+ return {
+ id: 'slow-scan',
+ label: 'Slow firewall scans',
+ severity: 'warn',
+ count: slow.length,
+ examples: pickExamples(slow.sort((a, b) => b.duration_ms - a.duration_ms), 3),
+ hint: `Scans exceeding ${SLOW_SCAN_MS}ms. Check rule pack size (Layer 83) or pathological regex; consider Layer 61 Diagnostic to flag dead patterns.`,
+ };
+}
+
+export function detectHungMcp(spans) {
+ const hung = spans.filter((s) => s.name?.startsWith('mcp.') && (s.duration_ms || 0) > HUNG_MCP_MS);
+ if (hung.length === 0) return null;
+ return {
+ id: 'hung-mcp',
+ label: 'Hung MCP calls',
+ severity: hung.length >= 3 ? 'critical' : 'warn',
+ count: hung.length,
+ examples: pickExamples(hung, 3),
+ hint: `MCP calls over ${HUNG_MCP_MS}ms. Check Layer 19 bridge: stalled WebSocket relay or a tool returning a large payload.`,
+ };
+}
+
+export function detectDeadPatterns(spans) {
+ // Spans that emit `firewall.scan` with no rule hits across many runs
+ // suggest the active ruleset is under-firing. Only meaningful when
+ // we have ≥10 scans to look at.
+ const scans = spans.filter((s) => s.name === 'firewall.scan');
+ if (scans.length < 10) return null;
+ const empty = scans.filter((s) => (s.attributes?.evidenceCount ?? 0) === 0 && (s.attributes?.pressure ?? 0) < 0.05);
+ const ratio = empty.length / scans.length;
+ if (ratio < 0.6) return null;
+ return {
+ id: 'dead-patterns',
+ label: 'Under-firing ruleset',
+ severity: 'warn',
+ count: empty.length,
+ examples: pickExamples(empty, 3),
+ hint: `${Math.round(ratio * 100)}% of recent scans returned zero evidence. Run Layer 61 Diagnostic, or seed Layer 31 Brain Evolve from the latest red-team run.`,
+ };
+}
+
+export function detectFpHeavyScans(spans) {
+ // Scans with high pressure but low evidence count are suspicious —
+ // either feedback (Layer 93) calibration is off, or a single
+ // pattern is dominating. Use feedback signal if we have it.
+ const fb = spans.filter((s) => s.name === 'firewall.scan' && s.attributes?.feedback === 'too_hot');
+ if (fb.length < 2) return null;
+ return {
+ id: 'fp-heavy',
+ label: 'False-positive complaints',
+ severity: 'warn',
+ count: fb.length,
+ examples: pickExamples(fb, 3),
+ hint: 'Multiple "too hot" calibration ratings. Inspect Coverage Heatmap (Layer 66) for the pattern that fired and consider widening it.',
+ };
+}
+
+export function detectAnnotatedFalsePositives(spans) {
+ // Layer 105 — operators flagging spans as false-positive is a strong
+ // direct signal that the active ruleset is mis-firing
+ const fps = spans.filter((s) => s.attributes?._annotation === 'false-positive');
+ if (fps.length < 2) return null;
+ return {
+ id: 'annotated-fp',
+ label: 'Operator-flagged false positives',
+ severity: fps.length >= 5 ? 'critical' : 'warn',
+ count: fps.length,
+ examples: pickExamples(fps, 3),
+ hint: 'Operators marked these spans as over-firing. Inspect the attributes — likely a single regex pattern catching benign text. Run Layer 66 Coverage Heatmap.',
+ };
+}
+
+export function detectRefusalLoop(spans) {
+ // Three consecutive same-name error spans within 30s = loop
+ if (spans.length < 3) return null;
+ const sorted = spans.slice().sort((a, b) => (a.end_time || 0) - (b.end_time || 0));
+ for (let i = 0; i <= sorted.length - 3; i++) {
+ const a = sorted[i];
+ const b = sorted[i + 1];
+ const c = sorted[i + 2];
+ if (
+ a.status?.code === 'error'
+ && b.status?.code === 'error'
+ && c.status?.code === 'error'
+ && a.name === b.name && b.name === c.name
+ && (c.end_time - a.end_time) < 30_000
+ ) {
+ return {
+ id: 'refusal-loop',
+ label: 'Refusal loop',
+ severity: 'critical',
+ count: 3,
+ examples: pickExamples([a, b, c], 3),
+ hint: `Three consecutive ${a.name} errors within 30s. Likely the same upstream failure being retried — surface to user, don't auto-retry.`,
+ };
+ }
+ }
+ return null;
+}
+
+/* ------------------------------ runner ------------------------------- */
+
+const ALL_DETECTORS = [
+ detectErrorBursts,
+ detectRefusalLoop,
+ detectHungMcp,
+ detectSlowScans,
+ detectDeadPatterns,
+ detectFpHeavyScans,
+ detectAnnotatedFalsePositives,
+];
+
+/**
+ * Run every detector and produce the unified harness diagnostic
+ * report. Optional `liftAttributes` lets you mine which attribute
+ * values correlate with errors.
+ */
+export function runDiagnostic({
+ spans = recentSpans(500),
+ liftAttributes = ['name', 'tool'],
+} = {}) {
+ // Layer 105 — fold operator annotations into span attributes so
+ // detectors and the lift miner can see them
+ const decorated = decorateSpansWithAnnotations(spans);
+ const findings = [];
+ for (const fn of ALL_DETECTORS) {
+ try {
+ const f = fn(decorated);
+ if (f) findings.push(f);
+ } catch { /* a single broken detector mustn't take down the report */ }
+ }
+ spans = decorated;
+
+ const byName = aggregateByName(spans);
+ const errorLift = mineSpansByOutcome(spans, {
+ attributeKeys: liftAttributes,
+ isPositive: (s) => s.status?.code === 'error',
+ minPositive: 2,
+ minLift: 2,
+ topK: 8,
+ });
+
+ const totals = {
+ spans: spans.length,
+ errors: spans.filter((s) => s.status?.code === 'error').length,
+ distinctNames: byName.length,
+ };
+
+ const tier = (
+ findings.some((f) => f.severity === 'critical') ? 'critical'
+ : findings.some((f) => f.severity === 'warn') ? 'warn'
+ : 'healthy'
+ );
+
+ return {
+ brainsnn: 'harness-report-v1',
+ generatedAt: new Date().toISOString(),
+ tier,
+ totals,
+ findings,
+ aggregates: byName.slice(0, 12),
+ errorLift: errorLift.candidates,
+ };
+}
+
+/**
+ * Render the report as a compact text block — easy to copy-paste into
+ * Cursor / Claude Code with a "fix what you can" prompt.
+ */
+export function renderReportText(report) {
+ if (!report) return '';
+ const lines = [];
+ lines.push(`# BrainSNN Harness Diagnostic — ${report.tier.toUpperCase()}`);
+ lines.push(`Generated ${report.generatedAt}`);
+ lines.push(`Spans: ${report.totals.spans} · Errors: ${report.totals.errors} · Distinct ops: ${report.totals.distinctNames}`);
+ lines.push('');
+ if (report.findings.length === 0) {
+ lines.push('No failure modes detected.');
+ } else {
+ lines.push('## Findings');
+ for (const f of report.findings) {
+ lines.push(`- [${f.severity}] ${f.label} ×${f.count}`);
+ if (f.hint) lines.push(` hint: ${f.hint}`);
+ }
+ }
+ if (report.errorLift?.length) {
+ lines.push('');
+ lines.push('## Error-correlated features (Laplace lift)');
+ for (const c of report.errorLift) {
+ lines.push(`- ${c.feature} · lift=${c.lift} · err=${c.posCount} ok=${c.negCount}`);
+ }
+ }
+ return lines.join('\n');
+}
diff --git a/brainsnn-r3f-app/src/utils/harnessLift.js b/brainsnn-r3f-app/src/utils/harnessLift.js
new file mode 100644
index 0000000..25e718c
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/harnessLift.js
@@ -0,0 +1,151 @@
+/**
+ * Layer 102 — Harness Lift Miner (generalized)
+ *
+ * Layer 27 (Adversarial Training) mines n-gram lift between attack vs
+ * benign text to grow the firewall. HALO does the same trick over
+ * trace spans: which span attribute values are over-represented in
+ * "bad" outcomes vs "good" ones?
+ *
+ * This module generalizes the lift idea so it works on any labelled
+ * collection of records — spans, scans, or arbitrary {features, label}
+ * tuples — and surfaces the top-K most discriminative features.
+ *
+ * mineLift(records, { features, isPositive, minPositive, minLift })
+ *
+ * features: (record) => string[] — extract feature tokens
+ * isPositive: (record) => boolean — positive vs negative class
+ *
+ * Returns:
+ * {
+ * candidates: [{ feature, lift, posCount, negCount }, …],
+ * stats: { positive, negative, candidates }
+ * }
+ *
+ * Laplace-smoothed lift = P(f|pos) / P(f|neg). Feature is interesting
+ * if pos-count >= minPositive AND lift >= minLift.
+ */
+
+const STOPWORDS = new Set([
+ 'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been',
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
+ 'of', 'to', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'as', 'it', 'this',
+ 'that', 'these', 'those', 'i', 'you', 'we', 'they', 'so', 'if', 'then', 'than',
+]);
+
+export function tokenize(text) {
+ return String(text || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9\s']/g, ' ')
+ .split(/\s+/)
+ .filter((w) => w.length > 1 && !STOPWORDS.has(w));
+}
+
+export function ngrams(tokens, n) {
+ const out = [];
+ for (let i = 0; i <= tokens.length - n; i++) {
+ out.push(tokens.slice(i, i + n).join(' '));
+ }
+ return out;
+}
+
+export function textNgrams(text, sizes = [2, 3]) {
+ const toks = tokenize(text);
+ const out = [];
+ for (const n of sizes) out.push(...ngrams(toks, n));
+ return out;
+}
+
+/**
+ * Generic lift miner. Works on any labelled records once you give it
+ * a feature-extraction function.
+ */
+export function mineLift(records, {
+ features,
+ isPositive,
+ minPositive = 2,
+ minLift = 3,
+ topK = 20,
+} = {}) {
+ if (!Array.isArray(records) || records.length === 0 || !features || !isPositive) {
+ return { candidates: [], stats: { positive: 0, negative: 0, candidates: 0 } };
+ }
+
+ const posCounts = new Map();
+ const negCounts = new Map();
+ let posTotal = 0;
+ let negTotal = 0;
+
+ for (const r of records) {
+ const feats = features(r) || [];
+ if (feats.length === 0) continue;
+ if (isPositive(r)) {
+ posTotal += 1;
+ const seen = new Set();
+ for (const f of feats) {
+ if (seen.has(f)) continue;
+ seen.add(f);
+ posCounts.set(f, (posCounts.get(f) || 0) + 1);
+ }
+ } else {
+ negTotal += 1;
+ const seen = new Set();
+ for (const f of feats) {
+ if (seen.has(f)) continue;
+ seen.add(f);
+ negCounts.set(f, (negCounts.get(f) || 0) + 1);
+ }
+ }
+ }
+
+ if (posTotal === 0) {
+ return { candidates: [], stats: { positive: 0, negative: negTotal, candidates: 0 } };
+ }
+
+ const candidates = [];
+ for (const [f, pc] of posCounts.entries()) {
+ if (pc < minPositive) continue;
+ const nc = negCounts.get(f) || 0;
+ const pPos = (pc + 0.5) / (posTotal + 1);
+ const pNeg = (nc + 0.5) / (negTotal + 1);
+ const lift = pPos / pNeg;
+ if (lift >= minLift) {
+ candidates.push({
+ feature: f,
+ lift: Number(lift.toFixed(2)),
+ posCount: pc,
+ negCount: nc,
+ });
+ }
+ }
+
+ candidates.sort((a, b) => b.lift - a.lift || b.posCount - a.posCount);
+
+ return {
+ candidates: candidates.slice(0, topK),
+ stats: { positive: posTotal, negative: negTotal, candidates: candidates.length },
+ };
+}
+
+/**
+ * Convenience wrapper: mine lift over a collection of telemetry spans
+ * by extracting the span name + selected attribute keys as features.
+ */
+export function mineSpansByOutcome(spans, {
+ attributeKeys = [],
+ isPositive,
+ minPositive = 2,
+ minLift = 3,
+ topK = 20,
+} = {}) {
+ const features = (span) => {
+ const out = [`name:${span.name}`];
+ for (const key of attributeKeys) {
+ const v = span.attributes?.[key];
+ if (v == null) continue;
+ out.push(`${key}:${String(v).toLowerCase()}`);
+ }
+ if (span.status?.code) out.push(`status:${span.status.code}`);
+ return out;
+ };
+ return mineLift(spans, { features, isPositive, minPositive, minLift, topK });
+}
diff --git a/brainsnn-r3f-app/src/utils/harnessProposer.js b/brainsnn-r3f-app/src/utils/harnessProposer.js
new file mode 100644
index 0000000..b8ada9d
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/harnessProposer.js
@@ -0,0 +1,133 @@
+/**
+ * Layer 102 — Harness Proposer
+ *
+ * Translate a harness diagnostic report into actionable proposals a
+ * coding agent (Cursor, Claude Code, Codex) — or a self-driving Brain
+ * Steward — can apply. This is the "report → fix" half of HALO's
+ * loop, scoped to the Brain's specific lever set:
+ *
+ * - propose Layer 55 custom-rule additions from error-correlated
+ * n-gram lift candidates (only those that look like manipulation
+ * vocabulary)
+ * - propose Layer 31 Brain Evolve runs when dead-pattern findings
+ * dominate
+ * - propose Layer 61 Diagnostic / Layer 66 Coverage when slow-scan
+ * or fp-heavy findings dominate
+ *
+ * Output schema (rule-diff-v1):
+ * {
+ * brainsnn: 'rule-diff-v1',
+ * generatedAt, reportTier,
+ * additions: [{ category, pattern, label, source }],
+ * followUps: [{ layer, action, reason }],
+ * }
+ *
+ * Pure function — no I/O. The agent decides whether to apply.
+ */
+
+const MANIP_HINT = {
+ urgency: /\b(now|urgent|hurry|immediately|fast|deadline|expir|countdown|limited)\b/i,
+ outrage: /\b(outrage|disgust|shock|scandal|betray|fraud|disgrace|exploit)\b/i,
+ fear: /\b(danger|threat|attack|fear|panic|crisis|crash|collapse|warn)\b/i,
+ certainty: /\b(proven|guaranteed|certain|absolute|undeniabl|fact|100%|always|never)\b/i,
+};
+
+function classifyFeature(feature) {
+ // Strip prefix tokens like "name:" / "tool:" — keep only the value
+ const value = feature.includes(':') ? feature.split(':').slice(1).join(':') : feature;
+ for (const [cat, rx] of Object.entries(MANIP_HINT)) {
+ if (rx.test(value)) return { category: cat, value };
+ }
+ return null;
+}
+
+function escapeRegex(s) {
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function suggestPatternFromFeature(value) {
+ // n-gram features arrive lowercased + space-separated; turn into a
+ // case-insensitive word-boundary regex
+ const tokens = value.split(/\s+/).filter(Boolean).map(escapeRegex);
+ if (tokens.length === 0) return null;
+ return `\\b${tokens.join('\\s+')}\\b`;
+}
+
+export function proposeRuleDiff(report, { topK = 6 } = {}) {
+ const additions = [];
+ const followUps = [];
+
+ if (!report || report.brainsnn !== 'harness-report-v1') {
+ return {
+ brainsnn: 'rule-diff-v1',
+ generatedAt: new Date().toISOString(),
+ reportTier: report?.tier || 'unknown',
+ additions,
+ followUps,
+ };
+ }
+
+ // 1) Mine error-correlated lift candidates that smell like manipulation
+ const seen = new Set();
+ for (const c of report.errorLift || []) {
+ const cls = classifyFeature(c.feature);
+ if (!cls) continue;
+ if (seen.has(cls.value)) continue;
+ seen.add(cls.value);
+ const pattern = suggestPatternFromFeature(cls.value);
+ if (!pattern) continue;
+ additions.push({
+ category: cls.category,
+ pattern,
+ label: `auto-${cls.category}-${seen.size}`,
+ source: `error-lift:${c.lift}`,
+ });
+ if (additions.length >= topK) break;
+ }
+
+ // 2) Map findings → next-action recommendations
+ const findingIds = new Set((report.findings || []).map((f) => f.id));
+ if (findingIds.has('dead-patterns')) {
+ followUps.push({
+ layer: 31,
+ action: 'run-brain-evolve',
+ reason: 'Active ruleset is under-firing on recent scans; seed an evolution round from the latest red-team corpus.',
+ });
+ }
+ if (findingIds.has('slow-scan')) {
+ followUps.push({
+ layer: 61,
+ action: 'run-diagnostic',
+ reason: 'Scan latency over threshold; audit the active ruleset for dead or pathological patterns.',
+ });
+ }
+ if (findingIds.has('fp-heavy')) {
+ followUps.push({
+ layer: 66,
+ action: 'open-coverage-heatmap',
+ reason: 'Repeated "too hot" calibration ratings; inspect which pattern is dominating each scan.',
+ });
+ }
+ if (findingIds.has('hung-mcp') || findingIds.has('refusal-loop')) {
+ followUps.push({
+ layer: 19,
+ action: 'inspect-mcp-bridge',
+ reason: 'MCP-side stalling or repeated errors; check the bridge context and the failing tool.',
+ });
+ }
+ if (findingIds.has('error-burst')) {
+ followUps.push({
+ layer: 21,
+ action: 'pause-steward',
+ reason: 'Error burst detected — pause the autopilot loop until root cause is understood.',
+ });
+ }
+
+ return {
+ brainsnn: 'rule-diff-v1',
+ generatedAt: new Date().toISOString(),
+ reportTier: report.tier,
+ additions,
+ followUps,
+ };
+}
diff --git a/brainsnn-r3f-app/src/utils/layerCatalog.js b/brainsnn-r3f-app/src/utils/layerCatalog.js
index 110bd86..6b25751 100644
--- a/brainsnn-r3f-app/src/utils/layerCatalog.js
+++ b/brainsnn-r3f-app/src/utils/layerCatalog.js
@@ -107,6 +107,20 @@ export const LAYER_CATALOG = [
{ id: 98, name: 'Theme + A11y', group: 'view', blurb: 'Dark/light, high-contrast, reduced-motion, font scale.' },
{ id: 99, name: 'Federated Community Firewall', group: 'firewall', blurb: 'Weekly-rotated community rule pack.' },
{ id: 100, name: 'Milestone Dashboard', group: 'view', blurb: '100 layers shipped — synthesis + personal stats.' },
+ { id: 101, name: 'Content Verification', group: 'share', blurb: 'ECDSA P-256 sign + chain-of-custody + humanity score.' },
+ { id: 102, name: 'Harness Diagnostic', group: 'backend', blurb: 'OTel-shape spans + lift mining + failure-mode detectors.' },
+ { id: 103, name: 'Auto-Apply Rule Steward', group: 'backend', blurb: 'Self-driving Layer 102 loop with kill-switch + quota.' },
+ { id: 104, name: 'Harness Comparator', group: 'backend', blurb: 'Snapshot + diff two harness reports — improved/regressed.' },
+ { id: 105, name: 'Span Annotation', group: 'backend', blurb: 'Operator labels (false-positive / real-bug) feed the diagnostic.' },
+ { id: 106, name: 'Trace Replay', group: 'backend', blurb: 'Scrub the telemetry buffer like a recording.' },
+ { id: 107, name: 'OTLP Exporter', group: 'backend', blurb: 'Push spans to any OTLP-HTTP collector (Honeycomb / Tempo / Datadog).' },
+ { id: 108, name: 'Telemetry Sanitizer', group: 'data', blurb: 'PII redaction (email/phone/IP/SSN/card/token + custom regex).' },
+ { id: 109, name: 'Harness Alerts', group: 'backend', blurb: 'Toast on tier shifts + new findings, debounced.' },
+ { id: 110, name: 'Trace-Driven Tour', group: 'view', blurb: 'Personalized next-step tour derived from your live usage.' },
+ { id: 111, name: 'Span Distribution', group: 'backend', blurb: 'Per-name duration histogram + p50/p90/p95/p99.' },
+ { id: 112, name: 'Trace Search', group: 'backend', blurb: 'Mini query language over the span buffer (=, !=, >, <).' },
+ { id: 113, name: 'Diagnostic Snapshots', group: 'backend', blurb: 'Auto-snap a Layer 104 entry on every tier shift.' },
+ { id: 114, name: 'MCP Tool Usage', group: 'backend', blurb: 'Hot / slow / flaky / dead detector across BRAIN_TOOLS.' },
];
export const LAYER_GROUPS = {
diff --git a/brainsnn-r3f-app/src/utils/mcpBridge.js b/brainsnn-r3f-app/src/utils/mcpBridge.js
index e22b095..c95f2b7 100644
--- a/brainsnn-r3f-app/src/utils/mcpBridge.js
+++ b/brainsnn-r3f-app/src/utils/mcpBridge.js
@@ -29,6 +29,9 @@ import { mergeTemplateResults } from './semanticTemplates';
import { pickTodaysChallenge } from './dailyChallenge';
import { analyzeTimeSeries } from './timeSeries';
import { issueReceipt } from './receipt';
+import { recordSpan } from './telemetry.js';
+import { runDiagnostic, renderReportText } from './harnessFailureModes.js';
+import { proposeRuleDiff } from './harnessProposer.js';
// ---------- tool catalog ----------
@@ -232,6 +235,24 @@ export const BRAIN_TOOLS = [
properties: { text: { type: 'string' } },
required: ['text']
}
+ },
+ {
+ name: 'get_harness_report',
+ description: 'Layer 102 — return the harness diagnostic report (failure modes, lift candidates, top ops). Use format="text" to get a copy-paste-into-coding-agent block.',
+ inputSchema: {
+ type: 'object',
+ properties: { format: { type: 'string', enum: ['json', 'text'], default: 'json' } },
+ required: []
+ }
+ },
+ {
+ name: 'propose_rule_diff',
+ description: 'Layer 102 — derive a candidate Layer 55 custom-rule diff from the current harness report. Returns suggested patterns + categories an agent can apply via setActiveRules().',
+ inputSchema: {
+ type: 'object',
+ properties: { topK: { type: 'integer', minimum: 1, maximum: 20, default: 6 } },
+ required: []
+ }
}
];
@@ -270,12 +291,27 @@ export async function handleToolCall(name, args = {}) {
if (bridgeContext.onToolCall) {
bridgeContext.onToolCall({ name, args, result, ms: Date.now() - t0, ok: true });
}
+ // Layer 102 — telemetry: every MCP tool call is a span
+ recordSpan({
+ name: 'mcp.tool',
+ kind: 'server',
+ start_time: t0,
+ attributes: { tool: name, argKeys: Object.keys(args || {}).join(',') },
+ status: { code: 'ok' },
+ });
return { ok: true, result };
} catch (err) {
const payload = { ok: false, error: err.message || String(err) };
if (bridgeContext.onToolCall) {
bridgeContext.onToolCall({ name, args, result: payload, ms: Date.now() - t0, ok: false });
}
+ recordSpan({
+ name: 'mcp.tool',
+ kind: 'server',
+ start_time: t0,
+ attributes: { tool: name, argKeys: Object.keys(args || {}).join(',') },
+ status: { code: 'error', message: err.message || String(err) },
+ });
return payload;
}
}
@@ -431,6 +467,17 @@ async function dispatch(name, args) {
return await issueReceipt({ text: args.text, score: s });
}
+ case 'get_harness_report': {
+ const report = runDiagnostic();
+ if (args.format === 'text') return { text: renderReportText(report) };
+ return report;
+ }
+
+ case 'propose_rule_diff': {
+ const report = runDiagnostic();
+ return proposeRuleDiff(report, { topK: args.topK || 6 });
+ }
+
default:
throw new Error(`Unknown tool: ${name}`);
}
diff --git a/brainsnn-r3f-app/src/utils/mcpToolUsage.js b/brainsnn-r3f-app/src/utils/mcpToolUsage.js
new file mode 100644
index 0000000..7d3acd3
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/mcpToolUsage.js
@@ -0,0 +1,71 @@
+/**
+ * Layer 114 — MCP Tool Usage
+ *
+ * Cross-reference the registered MCP tools (Layer 19 BRAIN_TOOLS)
+ * with what's actually been called. Surfaces:
+ *
+ * - dead tools: registered but never called over the buffer window
+ * - hot tools: top-N by call count
+ * - slow tools: highest p95 duration
+ * - flaky tools: highest error rate
+ *
+ * Pure: takes BRAIN_TOOLS + spans, returns a report. Pairs nicely
+ * with Layer 102's dead-pattern detector (this is its MCP cousin).
+ */
+
+import { recentSpans } from './telemetry.js';
+import { BRAIN_TOOLS } from './mcpBridge.js';
+
+function percentile(arr, q) {
+ if (!arr.length) return 0;
+ const sorted = arr.slice().sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
+}
+
+export function computeUsage(spans = recentSpans(500)) {
+ const calls = spans.filter((s) => s.name === 'mcp.tool');
+ const byTool = new Map();
+ for (const s of calls) {
+ const tool = s.attributes?.tool || '(unknown)';
+ const b = byTool.get(tool) || { tool, count: 0, errors: 0, durations: [] };
+ b.count += 1;
+ if (s.status?.code === 'error') b.errors += 1;
+ if (typeof s.duration_ms === 'number') b.durations.push(s.duration_ms);
+ byTool.set(tool, b);
+ }
+
+ const stats = [...byTool.values()].map((b) => ({
+ tool: b.tool,
+ count: b.count,
+ errors: b.errors,
+ errorRate: b.count > 0 ? Number((b.errors / b.count).toFixed(3)) : 0,
+ p50: percentile(b.durations, 0.5),
+ p95: percentile(b.durations, 0.95),
+ })).sort((a, b) => b.count - a.count);
+
+ const calledNames = new Set(stats.map((s) => s.tool));
+ const dead = (BRAIN_TOOLS || [])
+ .filter((t) => !calledNames.has(t.name))
+ .map((t) => ({ tool: t.name, description: t.description }));
+
+ const hot = stats.slice(0, 6);
+ const slow = stats.slice().sort((a, b) => b.p95 - a.p95).filter((s) => s.p95 > 0).slice(0, 5);
+ const flaky = stats.slice().sort((a, b) => b.errorRate - a.errorRate).filter((s) => s.errorRate > 0).slice(0, 5);
+
+ return {
+ brainsnn: 'mcp-usage-v1',
+ generatedAt: new Date().toISOString(),
+ totals: {
+ calls: calls.length,
+ errors: calls.filter((s) => s.status?.code === 'error').length,
+ registered: (BRAIN_TOOLS || []).length,
+ called: stats.length,
+ dead: dead.length,
+ },
+ hot,
+ slow,
+ flaky,
+ dead,
+ stats,
+ };
+}
diff --git a/brainsnn-r3f-app/src/utils/otlpExporter.js b/brainsnn-r3f-app/src/utils/otlpExporter.js
new file mode 100644
index 0000000..3dc6101
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/otlpExporter.js
@@ -0,0 +1,184 @@
+/**
+ * Layer 107 — OTLP HTTP Exporter
+ *
+ * Push the unified span buffer (Layer 102) to any OpenTelemetry
+ * collector that speaks OTLP-HTTP/JSON. Lets BrainSNN telemetry
+ * land in Honeycomb, Grafana Tempo, Datadog, Jaeger via the OTel
+ * collector — same shape HALO uses upstream.
+ *
+ * Conversion: our internal span format already mirrors OTel; this
+ * module just maps field names + envelopes them as a ResourceSpans
+ * payload. Time fields go to nanoseconds.
+ *
+ * No side-effects unless explicitly invoked. The endpoint URL +
+ * optional auth header live in localStorage so users can flip
+ * collectors without recompiling.
+ *
+ * Gated by user opt-in: nothing leaves the device until enableExport()
+ * is called and an endpoint is set.
+ */
+
+import { recentSpans } from './telemetry.js';
+import { sanitizeSpans } from './telemetrySanitizer.js';
+
+const CONFIG_KEY = 'brainsnn_otlp_config_v1';
+const RESULTS_KEY = 'brainsnn_otlp_results_v1';
+const MAX_RESULTS = 20;
+
+const DEFAULT_CONFIG = {
+ enabled: false,
+ endpoint: '',
+ authHeader: '',
+ serviceName: 'brainsnn',
+ batchSize: 100,
+};
+
+export function loadConfig() {
+ try {
+ const raw = localStorage.getItem(CONFIG_KEY);
+ return raw ? { ...DEFAULT_CONFIG, ...JSON.parse(raw) } : { ...DEFAULT_CONFIG };
+ } catch { return { ...DEFAULT_CONFIG }; }
+}
+
+export function saveConfig(partial) {
+ const next = { ...loadConfig(), ...partial };
+ try { localStorage.setItem(CONFIG_KEY, JSON.stringify(next)); } catch { /* noop */ }
+ return next;
+}
+
+export function exportResults() {
+ try { return JSON.parse(localStorage.getItem(RESULTS_KEY) || '[]'); }
+ catch { return []; }
+}
+
+function logResult(entry) {
+ try {
+ const next = [entry, ...exportResults()].slice(0, MAX_RESULTS);
+ localStorage.setItem(RESULTS_KEY, JSON.stringify(next));
+ } catch { /* noop */ }
+}
+
+/* ------------------------------ mapping ------------------------------ */
+
+function attrToOtlp(attributes = {}) {
+ return Object.entries(attributes).map(([key, value]) => ({
+ key,
+ value: typeAttrValue(value),
+ }));
+}
+
+function typeAttrValue(v) {
+ if (typeof v === 'number') return Number.isInteger(v) ? { intValue: v } : { doubleValue: v };
+ if (typeof v === 'boolean') return { boolValue: v };
+ if (Array.isArray(v)) return { arrayValue: { values: v.map(typeAttrValue) } };
+ return { stringValue: String(v) };
+}
+
+const STATUS_CODE = { ok: 1, error: 2, unset: 0 };
+
+function spanToOtlp(span) {
+ return {
+ traceId: padHex(span.trace_id, 32),
+ spanId: padHex(span.span_id, 16),
+ parentSpanId: span.parent_span_id ? padHex(span.parent_span_id, 16) : '',
+ name: span.name,
+ kind: kindFor(span.kind),
+ startTimeUnixNano: msToNano(span.start_time),
+ endTimeUnixNano: msToNano(span.end_time ?? span.start_time),
+ attributes: attrToOtlp(span.attributes),
+ events: (span.events || []).map((e) => ({
+ timeUnixNano: msToNano(e.time),
+ name: e.name,
+ attributes: attrToOtlp(e.attributes),
+ })),
+ status: {
+ code: STATUS_CODE[span.status?.code] ?? 0,
+ message: span.status?.message || '',
+ },
+ };
+}
+
+function padHex(s, len) {
+ const t = String(s || '').padStart(len, '0');
+ return t.length === len ? t : t.slice(-len);
+}
+
+function msToNano(ms) {
+ if (ms == null) return '0';
+ return String(Math.floor(ms) * 1_000_000);
+}
+
+function kindFor(k) {
+ // 1=internal, 2=server, 3=client, 4=producer, 5=consumer, 0=unspecified
+ return ({ internal: 1, server: 2, client: 3, producer: 4, consumer: 5 })[k] ?? 0;
+}
+
+export function buildPayload({ spans, serviceName }) {
+ return {
+ resourceSpans: [
+ {
+ resource: {
+ attributes: [
+ { key: 'service.name', value: { stringValue: serviceName || 'brainsnn' } },
+ { key: 'telemetry.sdk.language', value: { stringValue: 'javascript' } },
+ { key: 'telemetry.sdk.name', value: { stringValue: 'brainsnn-layer-107' } },
+ ],
+ },
+ scopeSpans: [
+ {
+ scope: { name: 'brainsnn.harness', version: '1.0.0' },
+ spans: spans.map(spanToOtlp),
+ },
+ ],
+ },
+ ],
+ };
+}
+
+/* ------------------------------- send -------------------------------- */
+
+export async function exportSpansOnce({ spans, dryRun = false } = {}) {
+ const cfg = loadConfig();
+ if (!cfg.enabled && !dryRun) {
+ return { ok: false, reason: 'disabled' };
+ }
+ if (!cfg.endpoint && !dryRun) {
+ return { ok: false, reason: 'no-endpoint' };
+ }
+
+ const buffer = spans || recentSpans(cfg.batchSize);
+ if (buffer.length === 0) return { ok: false, reason: 'empty-buffer' };
+
+ // Layer 108 — always sanitize before egress
+ const slice = sanitizeSpans(buffer.slice(0, cfg.batchSize));
+ const payload = buildPayload({ spans: slice, serviceName: cfg.serviceName });
+
+ if (dryRun) {
+ const summary = { ts: Date.now(), count: slice.length, dryRun: true, ok: true };
+ logResult(summary);
+ return { ok: true, dryRun: true, payload, summary };
+ }
+
+ try {
+ const res = await fetch(cfg.endpoint, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ ...(cfg.authHeader ? { authorization: cfg.authHeader } : {}),
+ },
+ body: JSON.stringify(payload),
+ });
+ const ok = res.ok;
+ const summary = { ts: Date.now(), count: slice.length, ok, status: res.status };
+ logResult(summary);
+ return { ok, status: res.status };
+ } catch (err) {
+ const summary = { ts: Date.now(), count: slice.length, ok: false, error: err.message || String(err) };
+ logResult(summary);
+ return { ok: false, error: err.message || String(err) };
+ }
+}
+
+export function clearResults() {
+ try { localStorage.removeItem(RESULTS_KEY); } catch { /* noop */ }
+}
diff --git a/brainsnn-r3f-app/src/utils/spanAnnotation.js b/brainsnn-r3f-app/src/utils/spanAnnotation.js
new file mode 100644
index 0000000..036d8e5
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/spanAnnotation.js
@@ -0,0 +1,105 @@
+/**
+ * Layer 105 — Span Annotation
+ *
+ * The harness diagnostic gets sharper when humans label spans:
+ * "this scan was a false positive", "this rule fired on benign text",
+ * "this MCP call was a real bug". Annotations get folded into the
+ * lift miner so user feedback shows up in the next report.
+ *
+ * Storage: keyed by span_id → annotation. Capped, persisted in
+ * localStorage. Annotated spans are pulled into runDiagnostic() as
+ * extra signal (e.g. an "fp-marked" attribute).
+ *
+ * Annotation labels (defaults):
+ * - false-positive : the span result was wrong (over-fired)
+ * - false-negative : missed something real
+ * - real-bug : confirmed actual bug
+ * - benign : looked bad but was fine
+ * - investigate : todo for the operator
+ *
+ * Custom labels are allowed; they're stored verbatim.
+ */
+
+const STORE_KEY = 'brainsnn_span_annotations_v1';
+const MAX_ANNOTATIONS = 200;
+
+export const STANDARD_LABELS = [
+ { id: 'false-positive', tone: '#fdab43', desc: 'Over-fired — score should have been lower' },
+ { id: 'false-negative', tone: '#dd6974', desc: 'Missed something — score should have been higher' },
+ { id: 'real-bug', tone: '#dd6974', desc: 'Confirmed an actual bug in the harness' },
+ { id: 'benign', tone: '#5ee69a', desc: 'Looked alarming but was fine' },
+ { id: 'investigate', tone: '#7a8fe7', desc: 'Worth a closer look later' },
+];
+
+export function listAnnotations() {
+ try {
+ return JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
+ } catch { return {}; }
+}
+
+function persist(map) {
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(map)); } catch { /* noop */ }
+}
+
+export function annotate({ spanId, label, note }) {
+ if (!spanId || !label) return;
+ const map = listAnnotations();
+ map[spanId] = {
+ spanId,
+ label,
+ note: (note || '').slice(0, 200),
+ ts: Date.now(),
+ };
+ // Cap entries by recency
+ const entries = Object.values(map).sort((a, b) => b.ts - a.ts).slice(0, MAX_ANNOTATIONS);
+ const trimmed = Object.fromEntries(entries.map((e) => [e.spanId, e]));
+ persist(trimmed);
+ return trimmed[spanId];
+}
+
+export function unannotate(spanId) {
+ const map = listAnnotations();
+ delete map[spanId];
+ persist(map);
+}
+
+export function getAnnotation(spanId) {
+ return listAnnotations()[spanId] || null;
+}
+
+export function clearAnnotations() {
+ try { localStorage.removeItem(STORE_KEY); } catch { /* noop */ }
+}
+
+/**
+ * Decorate a span buffer with `attributes._annotation` so the lift
+ * miner and detectors can use them.
+ */
+export function decorateSpansWithAnnotations(spans) {
+ const map = listAnnotations();
+ return spans.map((s) => {
+ const a = map[s.span_id];
+ if (!a) return s;
+ return {
+ ...s,
+ attributes: { ...s.attributes, _annotation: a.label },
+ };
+ });
+}
+
+/**
+ * Aggregate annotations into a small report — counts per label +
+ * recent entries. Cheap input for the diagnostic UI.
+ */
+export function annotationStats() {
+ const map = listAnnotations();
+ const byLabel = new Map();
+ for (const a of Object.values(map)) {
+ byLabel.set(a.label, (byLabel.get(a.label) || 0) + 1);
+ }
+ const totals = [...byLabel.entries()]
+ .map(([label, count]) => ({ label, count }))
+ .sort((x, y) => y.count - x.count);
+ const recent = Object.values(map).sort((a, b) => b.ts - a.ts).slice(0, 12);
+ return { totals, recent, count: Object.keys(map).length };
+}
diff --git a/brainsnn-r3f-app/src/utils/spanDistribution.js b/brainsnn-r3f-app/src/utils/spanDistribution.js
new file mode 100644
index 0000000..14be0c7
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/spanDistribution.js
@@ -0,0 +1,70 @@
+/**
+ * Layer 111 — Span Distribution
+ *
+ * Duration histogram + percentiles per span name. Helps spot the
+ * tail-latency outliers that aggregateByName's p50/p95 hide.
+ *
+ * Buckets are log-scale by default (1 / 5 / 10 / 50 / 100 / 500 /
+ * 1000 / 5000 / >5000 ms) so a 50× spread compresses readably.
+ */
+
+import { recentSpans } from './telemetry.js';
+
+const DEFAULT_BUCKETS = [1, 5, 10, 50, 100, 500, 1000, 5000];
+
+function bucketIndex(durationMs, bounds) {
+ for (let i = 0; i < bounds.length; i++) {
+ if (durationMs <= bounds[i]) return i;
+ }
+ return bounds.length;
+}
+
+function percentile(arr, q) {
+ if (!arr.length) return 0;
+ const sorted = arr.slice().sort((a, b) => a - b);
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
+}
+
+export function distributionFor(spans = recentSpans(500), { bounds = DEFAULT_BUCKETS, name = null } = {}) {
+ const filtered = name ? spans.filter((s) => s.name === name) : spans;
+ if (!filtered.length) return null;
+
+ const durations = filtered
+ .map((s) => s.duration_ms)
+ .filter((d) => typeof d === 'number' && d >= 0);
+ if (!durations.length) return null;
+
+ const buckets = new Array(bounds.length + 1).fill(0);
+ for (const d of durations) buckets[bucketIndex(d, bounds)] += 1;
+
+ const labels = [...bounds.map((b) => `≤${b}`), `>${bounds[bounds.length - 1]}`];
+
+ return {
+ name: name || '(all)',
+ count: durations.length,
+ min: Math.min(...durations),
+ max: Math.max(...durations),
+ mean: Number((durations.reduce((a, b) => a + b, 0) / durations.length).toFixed(2)),
+ p50: percentile(durations, 0.5),
+ p90: percentile(durations, 0.9),
+ p95: percentile(durations, 0.95),
+ p99: percentile(durations, 0.99),
+ bounds,
+ buckets,
+ labels,
+ };
+}
+
+export function distributionsByName(spans = recentSpans(500), { topK = 8 } = {}) {
+ const byName = new Map();
+ for (const s of spans) {
+ if (!byName.has(s.name)) byName.set(s.name, []);
+ byName.get(s.name).push(s);
+ }
+ const out = [];
+ for (const [name, group] of byName) {
+ const d = distributionFor(group, { name });
+ if (d) out.push(d);
+ }
+ return out.sort((a, b) => b.count - a.count).slice(0, topK);
+}
diff --git a/brainsnn-r3f-app/src/utils/telemetry.js b/brainsnn-r3f-app/src/utils/telemetry.js
new file mode 100644
index 0000000..d0c72ad
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/telemetry.js
@@ -0,0 +1,225 @@
+/**
+ * Layer 102 (foundation) — Telemetry / OpenTelemetry-shaped spans
+ *
+ * One unified trace buffer for every layer that wants to be observable.
+ * Schema borrows OpenTelemetry span fields directly so traces can later
+ * be exported into any OTel-compatible analyzer (this is what HALO uses
+ * for its harness-optimization loop in context-labs/halo).
+ *
+ * {
+ * trace_id, span_id, parent_span_id,
+ * name, kind, start_time, end_time, duration_ms,
+ * status: { code: 'ok' | 'error', message? },
+ * attributes: { ... },
+ * events: [{ name, time, attributes }]
+ * }
+ *
+ * Storage:
+ * - in-memory ring buffer of MAX_SPANS most recent ended spans
+ * - localStorage mirror, throttled, so the buffer survives reload
+ * - subscribers fire on every recorded span so panels can react live
+ *
+ * Usage:
+ * const span = startSpan('firewall.scan', { attributes: { lang: 'en' } });
+ * …work…
+ * endSpan(span, { status: { code: 'ok' }, attributes: { pressure } });
+ *
+ * // Or one-shot:
+ * recordSpan({ name: 'mcp.tool', attributes: { tool: 'get_state' }, status });
+ */
+
+const STORAGE_KEY = 'brainsnn_telemetry_spans_v1';
+const MAX_SPANS = 500;
+const PERSIST_DEBOUNCE_MS = 500;
+
+let _spans = loadFromStorage();
+const _subscribers = new Set();
+let _persistTimer = null;
+let _enabled = true;
+
+function loadFromStorage() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.slice(0, MAX_SPANS) : [];
+ } catch { return []; }
+}
+
+function schedulePersist() {
+ if (_persistTimer) return;
+ _persistTimer = setTimeout(() => {
+ _persistTimer = null;
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(_spans)); }
+ catch { /* quota — buffer keeps working in memory */ }
+ }, PERSIST_DEBOUNCE_MS);
+}
+
+function emit(span) {
+ for (const fn of _subscribers) {
+ try { fn(span); } catch { /* swallow subscriber errors */ }
+ }
+}
+
+function rngHex(bytes) {
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
+ const arr = new Uint8Array(bytes);
+ crypto.getRandomValues(arr);
+ return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
+ }
+ let out = '';
+ for (let i = 0; i < bytes * 2; i++) out += Math.floor(Math.random() * 16).toString(16);
+ return out;
+}
+
+function newTraceId() { return rngHex(8); }
+function newSpanId() { return rngHex(4); }
+
+/**
+ * Start an open span. Returns a handle — pass it to endSpan when done.
+ * If `parent` is supplied, inherits its trace_id and links via parent_span_id.
+ */
+export function startSpan(name, { kind = 'internal', attributes = {}, parent = null } = {}) {
+ const trace_id = parent?.trace_id || newTraceId();
+ const span_id = newSpanId();
+ return {
+ trace_id,
+ span_id,
+ parent_span_id: parent?.span_id || null,
+ name: String(name || 'unnamed'),
+ kind,
+ start_time: Date.now(),
+ end_time: null,
+ duration_ms: null,
+ status: { code: 'unset' },
+ attributes: { ...attributes },
+ events: [],
+ };
+}
+
+/**
+ * Add an event to an open span (analogous to OTel span events).
+ */
+export function addEvent(span, name, attributes = {}) {
+ if (!span) return;
+ span.events.push({ name: String(name), time: Date.now(), attributes });
+}
+
+/**
+ * Close a span and record it into the buffer.
+ */
+export function endSpan(span, { status, attributes, events } = {}) {
+ if (!span || span.end_time != null) return;
+ span.end_time = Date.now();
+ span.duration_ms = span.end_time - span.start_time;
+ if (status) span.status = status;
+ else if (span.status.code === 'unset') span.status = { code: 'ok' };
+ if (attributes) Object.assign(span.attributes, attributes);
+ if (events) span.events.push(...events);
+ recordSpan(span);
+}
+
+/**
+ * Record a fully-formed span (one-shot path; bypasses startSpan/endSpan).
+ */
+export function recordSpan(span) {
+ if (!_enabled || !span) return;
+ if (!span.trace_id) span.trace_id = newTraceId();
+ if (!span.span_id) span.span_id = newSpanId();
+ if (span.end_time == null) span.end_time = Date.now();
+ if (span.duration_ms == null) {
+ span.duration_ms = span.end_time - (span.start_time || span.end_time);
+ }
+ if (!span.status) span.status = { code: 'ok' };
+ _spans.unshift(span);
+ if (_spans.length > MAX_SPANS) _spans.length = MAX_SPANS;
+ schedulePersist();
+ emit(span);
+}
+
+/**
+ * Convenience: instrument a sync or async fn so its return-value /
+ * exception is captured as a span.
+ */
+export async function withSpan(name, fn, options = {}) {
+ const span = startSpan(name, options);
+ try {
+ const result = await fn(span);
+ endSpan(span, { status: { code: 'ok' } });
+ return result;
+ } catch (err) {
+ endSpan(span, {
+ status: { code: 'error', message: String(err?.message || err) },
+ attributes: { 'exception.type': err?.name || 'Error' },
+ });
+ throw err;
+ }
+}
+
+/* ------------------------------ readers ------------------------------ */
+
+export function recentSpans(limit = MAX_SPANS) {
+ return _spans.slice(0, Math.max(0, limit));
+}
+
+export function spanCount() { return _spans.length; }
+
+export function clearSpans() {
+ _spans = [];
+ try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ }
+ emit({ name: '_cleared', span_id: '0', trace_id: '0', start_time: Date.now(), end_time: Date.now(), duration_ms: 0, status: { code: 'ok' }, attributes: {}, events: [] });
+}
+
+export function subscribe(fn) {
+ _subscribers.add(fn);
+ return () => _subscribers.delete(fn);
+}
+
+export function setEnabled(v) { _enabled = !!v; }
+export function isEnabled() { return _enabled; }
+
+/**
+ * Export the buffer as a portable JSON envelope.
+ */
+export function exportSpans() {
+ return {
+ brainsnn: 'telemetry-v1',
+ exportedAt: new Date().toISOString(),
+ spanCount: _spans.length,
+ spans: _spans,
+ };
+}
+
+/**
+ * Group spans by name → { name, count, errorCount, p50, p95, lastTs }.
+ * Cheap aggregation for diagnostic panels.
+ */
+export function aggregateByName(spans = _spans) {
+ const buckets = new Map();
+ for (const s of spans) {
+ const b = buckets.get(s.name) || {
+ name: s.name, count: 0, errorCount: 0, durations: [], lastTs: 0,
+ };
+ b.count += 1;
+ if (s.status?.code === 'error') b.errorCount += 1;
+ if (typeof s.duration_ms === 'number') b.durations.push(s.duration_ms);
+ b.lastTs = Math.max(b.lastTs, s.end_time || s.start_time || 0);
+ buckets.set(s.name, b);
+ }
+ const out = [];
+ for (const b of buckets.values()) {
+ const sorted = b.durations.slice().sort((x, y) => x - y);
+ const p = (q) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] : 0);
+ out.push({
+ name: b.name,
+ count: b.count,
+ errorCount: b.errorCount,
+ errorRate: b.count ? b.errorCount / b.count : 0,
+ p50: p(0.5),
+ p95: p(0.95),
+ lastTs: b.lastTs,
+ });
+ }
+ out.sort((a, b) => b.count - a.count);
+ return out;
+}
diff --git a/brainsnn-r3f-app/src/utils/telemetrySanitizer.js b/brainsnn-r3f-app/src/utils/telemetrySanitizer.js
new file mode 100644
index 0000000..f99d4bf
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/telemetrySanitizer.js
@@ -0,0 +1,137 @@
+/**
+ * Layer 108 — Telemetry Sanitizer
+ *
+ * Run span attributes through a regex-based PII redactor before they
+ * leave the device (export, OTLP push, sync). Uses the standard
+ * email / phone / IP / SSN-like patterns. Custom patterns are
+ * persisted in localStorage so users can add domain-specific rules
+ * (employee IDs, internal slugs, etc).
+ *
+ * Sanitization is opt-in for the buffer ("redactInPlace") but the
+ * sanitized() helper is always used by export paths.
+ */
+
+const CUSTOM_KEY = 'brainsnn_sanitizer_patterns_v1';
+
+export const STANDARD_PATTERNS = [
+ { id: 'email', label: 'email', source: '[\\w.+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}' },
+ { id: 'phone', label: 'phone', source: '\\+?\\d[\\d\\s().-]{7,}\\d' },
+ { id: 'ipv4', label: 'IPv4', source: '\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b' },
+ { id: 'ssn', label: 'SSN-like', source: '\\b\\d{3}-\\d{2}-\\d{4}\\b' },
+ { id: 'card', label: 'credit-card', source: '\\b(?:\\d[ -]?){13,16}\\b' },
+ { id: 'token', label: 'bearer-token', source: 'Bearer\\s+[A-Za-z0-9\\-_.=]+' },
+];
+
+export function loadCustomPatterns() {
+ try { return JSON.parse(localStorage.getItem(CUSTOM_KEY) || '[]'); } catch { return []; }
+}
+
+function persistCustom(list) {
+ try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(list)); } catch { /* noop */ }
+}
+
+export function addCustomPattern({ label, source }) {
+ if (!source) throw new Error('source required');
+ // Validate by attempting to compile
+ try { new RegExp(source); } catch (e) { throw new Error(`invalid regex: ${e.message}`); }
+ const id = `cp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
+ const list = [...loadCustomPatterns(), { id, label: (label || source).slice(0, 32), source }].slice(-40);
+ persistCustom(list);
+ return list;
+}
+
+export function removeCustomPattern(id) {
+ persistCustom(loadCustomPatterns().filter((p) => p.id !== id));
+}
+
+export function clearCustomPatterns() {
+ try { localStorage.removeItem(CUSTOM_KEY); } catch { /* noop */ }
+}
+
+function buildRegex(patterns) {
+ return patterns.map((p) => {
+ try { return { ...p, re: new RegExp(p.source, 'gi') }; }
+ catch { return null; }
+ }).filter(Boolean);
+}
+
+/**
+ * Redact a single string against the active pattern list. Replaces
+ * every match with `[REDACTED:label]`.
+ */
+export function redactString(s, patterns) {
+ if (typeof s !== 'string' || !s) return s;
+ let out = s;
+ for (const p of patterns) {
+ out = out.replace(p.re, `[REDACTED:${p.label}]`);
+ }
+ return out;
+}
+
+function activePatterns() {
+ return buildRegex([...STANDARD_PATTERNS, ...loadCustomPatterns()]);
+}
+
+/**
+ * Walk a span's attributes + events.attributes and redact every
+ * string value. Numbers / bools pass through.
+ */
+export function sanitizeSpan(span, patterns = activePatterns()) {
+ if (!span) return span;
+ const cleanAttrs = (obj) => {
+ if (!obj) return obj;
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ out[k] = typeof v === 'string' ? redactString(v, patterns) : v;
+ }
+ return out;
+ };
+ return {
+ ...span,
+ attributes: cleanAttrs(span.attributes),
+ events: (span.events || []).map((e) => ({ ...e, attributes: cleanAttrs(e.attributes) })),
+ status: span.status?.message
+ ? { ...span.status, message: redactString(span.status.message, patterns) }
+ : span.status,
+ };
+}
+
+export function sanitizeSpans(spans) {
+ const patterns = activePatterns();
+ return spans.map((s) => sanitizeSpan(s, patterns));
+}
+
+/**
+ * Quick-check for a panel: how many of the recent spans contain
+ * one or more PII matches? Useful to nudge users to enable export
+ * sanitization before pushing to a collector.
+ */
+export function piiAuditSummary(spans) {
+ const patterns = activePatterns();
+ const counts = new Map();
+ let flagged = 0;
+ for (const s of spans) {
+ let hit = false;
+ const scan = (obj) => {
+ for (const v of Object.values(obj || {})) {
+ if (typeof v !== 'string') continue;
+ for (const p of patterns) {
+ if (p.re.test(v)) {
+ counts.set(p.label, (counts.get(p.label) || 0) + 1);
+ hit = true;
+ }
+ // reset because of /g flag stateful lastIndex
+ p.re.lastIndex = 0;
+ }
+ }
+ };
+ scan(s.attributes);
+ for (const e of s.events || []) scan(e.attributes);
+ if (hit) flagged += 1;
+ }
+ return {
+ flagged,
+ total: spans.length,
+ byLabel: [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count),
+ };
+}
diff --git a/brainsnn-r3f-app/src/utils/traceDrivenTour.js b/brainsnn-r3f-app/src/utils/traceDrivenTour.js
new file mode 100644
index 0000000..52d527f
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/traceDrivenTour.js
@@ -0,0 +1,109 @@
+/**
+ * Layer 110 — Trace-Driven Tour
+ *
+ * Layer 94 (Role Tour) is static — same six steps regardless of how
+ * the user actually uses the app. This layer reads the live span
+ * buffer + applied-rule log + scan archive, derives a personalized
+ * tour from actual usage patterns, and surfaces "you've spent most
+ * of your time on X — try Y next."
+ *
+ * Pure: takes the buffer + signals, emits a tour-v1 envelope. UI
+ * decides whether to render or skip.
+ */
+
+import { recentSpans, aggregateByName } from './telemetry.js';
+import { LAYER_CATALOG } from './layerCatalog.js';
+
+const STEP_LIBRARY = [
+ // Triggered when a particular signal fires
+ {
+ when: ({ topOps }) => topOps.includes('firewall.scan') && topOps.length === 1,
+ layerId: 88,
+ label: 'Try Persona Simulator',
+ body: 'You scan a lot. Persona Simulator runs the same text through 4 reader lenses — see where the manipulation lives between viewpoints.',
+ },
+ {
+ when: ({ totalsByName }) => (totalsByName['mcp.tool'] || 0) > 5,
+ layerId: 21,
+ label: 'Try Brain Steward',
+ body: 'You drive MCP tools manually. The Steward can run the loop for you — auto-snapshot on anomalies, narrate state changes.',
+ },
+ {
+ when: ({ errorRate }) => errorRate > 0.1,
+ layerId: 102,
+ label: 'Open Harness Diagnostic',
+ body: 'Your harness has a non-trivial error rate. Layer 102 clusters spans into named failure modes and proposes fixes.',
+ },
+ {
+ when: ({ totalsByName }) => (totalsByName['steward.tick'] || 0) > 2,
+ layerId: 103,
+ label: 'Try the Auto-Apply Steward',
+ body: 'The Steward runs. Layer 103 polls the diagnostic each cycle and applies low-risk rule diffs automatically — kill-switch always available.',
+ },
+ {
+ when: ({ topOps }) => topOps.includes('firewall.scan') && (topOps.length || 0) >= 2,
+ layerId: 104,
+ label: 'Snapshot before/after a change',
+ body: 'You scan often. Layer 104 lets you snapshot the harness report, make a change, snapshot again, and see exactly what shifted.',
+ },
+ {
+ when: ({ totalsByName }) => (totalsByName['firewall.scan'] || 0) > 10,
+ layerId: 67,
+ label: 'Open Calendar Heatmap',
+ body: 'You scan often enough that a 53-week activity grid will tell you something. Layer 67 buckets receipts + context by day.',
+ },
+ // Default fallback
+ {
+ when: () => true,
+ layerId: 72,
+ label: 'Browse the Layer Explorer',
+ body: 'Not sure where to start? Layer 72 indexes all 100+ layers with search + group filter.',
+ },
+];
+
+function lookupLayer(id) {
+ return LAYER_CATALOG.find((l) => l.id === id) || null;
+}
+
+export function deriveTour({ spans = recentSpans(500), maxSteps = 5 } = {}) {
+ const aggregates = aggregateByName(spans);
+ const totalsByName = Object.fromEntries(aggregates.map((a) => [a.name, a.count]));
+ const topOps = aggregates.slice(0, 3).map((a) => a.name);
+ const totalSpans = aggregates.reduce((s, a) => s + a.count, 0);
+ const totalErrors = aggregates.reduce((s, a) => s + a.errorCount, 0);
+ const errorRate = totalSpans > 0 ? totalErrors / totalSpans : 0;
+ const ctx = { aggregates, totalsByName, topOps, totalSpans, totalErrors, errorRate };
+
+ const steps = [];
+ const seen = new Set();
+ for (const t of STEP_LIBRARY) {
+ let trigger;
+ try { trigger = t.when(ctx); } catch { trigger = false; }
+ if (!trigger) continue;
+ if (seen.has(t.layerId)) continue;
+ seen.add(t.layerId);
+ const layer = lookupLayer(t.layerId);
+ steps.push({
+ layerId: t.layerId,
+ layerName: layer?.name || 'unknown',
+ group: layer?.group || 'view',
+ label: t.label,
+ body: t.body,
+ });
+ if (steps.length >= maxSteps) break;
+ }
+
+ const summary = (
+ totalSpans === 0
+ ? 'No usage yet — start with a Cognitive Firewall scan.'
+ : `You've run ${totalSpans} ops, ${(errorRate * 100).toFixed(0)}% errors. Top ops: ${topOps.join(', ')}.`
+ );
+
+ return {
+ brainsnn: 'tour-v1',
+ generatedAt: new Date().toISOString(),
+ summary,
+ context: { totalSpans, totalErrors, errorRate: Number(errorRate.toFixed(3)), topOps },
+ steps,
+ };
+}
diff --git a/brainsnn-r3f-app/src/utils/traceReplay.js b/brainsnn-r3f-app/src/utils/traceReplay.js
new file mode 100644
index 0000000..1967efa
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/traceReplay.js
@@ -0,0 +1,97 @@
+/**
+ * Layer 106 — Trace Replay
+ *
+ * Scrub the unified span buffer like a recording. Given the buffer:
+ * - Pick a window [from, to]
+ * - Walk it forward at 1×, 2×, 8× speed
+ * - At each tick, emit the spans that completed in that bucket
+ *
+ * Useful for "show me what the harness was doing during the
+ * outage at 14:32" without having to re-run the brain.
+ *
+ * Pure logic — UI handles wall-clock pacing via setInterval.
+ */
+
+import { recentSpans } from './telemetry.js';
+
+const STEP_KEY = 'brainsnn_replay_window_v1';
+
+export function loadWindow() {
+ try {
+ return JSON.parse(localStorage.getItem(STEP_KEY) || 'null');
+ } catch { return null; }
+}
+
+export function saveWindow(win) {
+ try { localStorage.setItem(STEP_KEY, JSON.stringify(win)); } catch { /* noop */ }
+}
+
+/**
+ * Build a chronological frame list. Each frame represents a slice of
+ * the buffer with the spans that ended within the bucket.
+ *
+ * frameMs: bucket size; smaller → finer granularity, more frames.
+ */
+export function buildFrames({ spans = recentSpans(500), from, to, frameMs = 1000 } = {}) {
+ if (!spans.length) return { frames: [], from: null, to: null, frameMs };
+ const sorted = spans
+ .filter((s) => typeof s.end_time === 'number')
+ .slice()
+ .sort((a, b) => a.end_time - b.end_time);
+ const start = from ?? sorted[0].end_time;
+ const end = to ?? sorted[sorted.length - 1].end_time;
+ if (end <= start) return { frames: [], from: start, to: end, frameMs };
+
+ const frames = [];
+ let bucketStart = start;
+ let cursor = 0;
+ while (bucketStart < end) {
+ const bucketEnd = Math.min(end, bucketStart + frameMs);
+ const items = [];
+ while (cursor < sorted.length && sorted[cursor].end_time <= bucketEnd) {
+ items.push(sorted[cursor]);
+ cursor += 1;
+ }
+ frames.push({
+ tStart: bucketStart,
+ tEnd: bucketEnd,
+ spans: items,
+ stats: {
+ count: items.length,
+ errors: items.filter((s) => s.status?.code === 'error').length,
+ names: [...new Set(items.map((s) => s.name))],
+ },
+ });
+ bucketStart = bucketEnd;
+ }
+ return { frames, from: start, to: end, frameMs };
+}
+
+/**
+ * Roll-forward state: walk frames, accumulate per-name running
+ * totals. Lets a UI show "spans by name as the replay progresses".
+ */
+export function rollForward(frames, upToIndex) {
+ const totals = new Map();
+ let errors = 0;
+ let count = 0;
+ for (let i = 0; i <= upToIndex && i < frames.length; i++) {
+ const f = frames[i];
+ count += f.stats.count;
+ errors += f.stats.errors;
+ for (const s of f.spans) {
+ const k = s.name;
+ const v = totals.get(k) || { count: 0, errors: 0 };
+ v.count += 1;
+ if (s.status?.code === 'error') v.errors += 1;
+ totals.set(k, v);
+ }
+ }
+ return {
+ count,
+ errors,
+ byName: [...totals.entries()]
+ .map(([name, v]) => ({ name, ...v }))
+ .sort((a, b) => b.count - a.count),
+ };
+}
diff --git a/brainsnn-r3f-app/src/utils/traceSearch.js b/brainsnn-r3f-app/src/utils/traceSearch.js
new file mode 100644
index 0000000..5c630fd
--- /dev/null
+++ b/brainsnn-r3f-app/src/utils/traceSearch.js
@@ -0,0 +1,96 @@
+/**
+ * Layer 112 — Trace Search
+ *
+ * Mini query language over the span buffer:
+ *
+ * name=firewall.scan status=ok duration>200 lang=en
+ * tool=get_brain_state status=error
+ * "act now" name=firewall.scan
+ *
+ * Operators: =, !=, >, <, >=, <=. Bare tokens become free-text matches
+ * over `name` + JSON-stringified attributes.
+ *
+ * Pure parser + filter — no I/O.
+ */
+
+import { recentSpans } from './telemetry.js';
+
+const TOKEN_RE = /(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|(\S+))/g;
+const OP_RE = /^([\w_.]+)(>=|<=|!=|>|<|=)(.*)$/;
+
+const FIELD_GETTERS = {
+ name: (s) => s.name,
+ status: (s) => s.status?.code,
+ duration: (s) => s.duration_ms,
+ duration_ms: (s) => s.duration_ms,
+ trace_id: (s) => s.trace_id,
+ span_id: (s) => s.span_id,
+ kind: (s) => s.kind,
+};
+
+function getField(s, field) {
+ if (FIELD_GETTERS[field]) return FIELD_GETTERS[field](s);
+ return s.attributes?.[field];
+}
+
+function compare(a, op, b) {
+ if (op === '=' || op === '!=') {
+ const ok = String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase();
+ return op === '=' ? ok : !ok;
+ }
+ const an = Number(a);
+ const bn = Number(b);
+ if (Number.isNaN(an) || Number.isNaN(bn)) {
+ return false;
+ }
+ switch (op) {
+ case '>': return an > bn;
+ case '<': return an < bn;
+ case '>=': return an >= bn;
+ case '<=': return an <= bn;
+ default: return false;
+ }
+}
+
+export function parseQuery(q) {
+ const tokens = [];
+ let m;
+ TOKEN_RE.lastIndex = 0;
+ while ((m = TOKEN_RE.exec(String(q || '')))) {
+ tokens.push(m[1] || m[2] || m[3]);
+ }
+ const clauses = [];
+ for (const t of tokens) {
+ const op = OP_RE.exec(t);
+ if (op) {
+ clauses.push({ kind: 'op', field: op[1], op: op[2], value: op[3] });
+ } else {
+ clauses.push({ kind: 'text', value: t });
+ }
+ }
+ return clauses;
+}
+
+export function matches(span, clauses) {
+ for (const c of clauses) {
+ if (c.kind === 'op') {
+ const v = getField(span, c.field);
+ if (!compare(v, c.op, c.value)) return false;
+ } else {
+ const blob = `${span.name} ${JSON.stringify(span.attributes || {})}`.toLowerCase();
+ if (!blob.includes(c.value.toLowerCase())) return false;
+ }
+ }
+ return true;
+}
+
+export function searchSpans(query, spans = recentSpans(500), { limit = 100 } = {}) {
+ if (!query || !query.trim()) return spans.slice(0, limit);
+ const clauses = parseQuery(query);
+ const out = [];
+ for (const s of spans) {
+ if (matches(s, clauses)) out.push(s);
+ if (out.length >= limit) break;
+ }
+ return out;
+}