@shield-agent/kya 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.mcp.json +1 -1
  2. package/README.md +6 -4
  3. package/amp/settings.example.json +1 -1
  4. package/claude/claude_desktop_config.example.json +1 -1
  5. package/cline/cline_mcp_settings.example.json +1 -1
  6. package/copilot/mcp-config.example.json +1 -1
  7. package/dist/certify/context.d.ts +11 -0
  8. package/dist/certify/context.d.ts.map +1 -0
  9. package/dist/certify/context.js +49 -0
  10. package/dist/certify/context.js.map +1 -0
  11. package/dist/certify/evaluate.d.ts +16 -1
  12. package/dist/certify/evaluate.d.ts.map +1 -1
  13. package/dist/certify/evaluate.js +46 -1
  14. package/dist/certify/evaluate.js.map +1 -1
  15. package/dist/certify/live.d.ts +3 -0
  16. package/dist/certify/live.d.ts.map +1 -0
  17. package/dist/certify/live.js +39 -0
  18. package/dist/certify/live.js.map +1 -0
  19. package/dist/certify/render.d.ts.map +1 -1
  20. package/dist/certify/render.js +1 -0
  21. package/dist/certify/render.js.map +1 -1
  22. package/dist/commands/certify.d.ts +1 -2
  23. package/dist/commands/certify.d.ts.map +1 -1
  24. package/dist/commands/certify.js +15 -74
  25. package/dist/commands/certify.js.map +1 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/receipt/enrich.d.ts +21 -0
  31. package/dist/receipt/enrich.d.ts.map +1 -1
  32. package/dist/receipt/enrich.js +37 -0
  33. package/dist/receipt/enrich.js.map +1 -1
  34. package/dist/receipt/receipt-css.d.ts +16 -0
  35. package/dist/receipt/receipt-css.d.ts.map +1 -0
  36. package/dist/receipt/receipt-css.js +508 -0
  37. package/dist/receipt/receipt-css.js.map +1 -0
  38. package/dist/receipt/render-receipt.d.ts +3 -1
  39. package/dist/receipt/render-receipt.d.ts.map +1 -1
  40. package/dist/receipt/render-receipt.js +214 -397
  41. package/dist/receipt/render-receipt.js.map +1 -1
  42. package/droid/README.md +1 -1
  43. package/droid/mcp.example.json +1 -1
  44. package/gemini/settings.example.json +1 -1
  45. package/grok/README.md +1 -1
  46. package/kilo/kilo.example.json +1 -1
  47. package/kimi/mcp.example.json +1 -1
  48. package/kiro/mcp.example.json +1 -1
  49. package/manifest.json +1 -1
  50. package/mastracode/mcp.example.json +1 -1
  51. package/mcp.json +1 -1
  52. package/openai/codex.config.example.toml +1 -1
  53. package/opencode/opencode.example.json +1 -1
  54. package/package.json +1 -1
  55. package/qwen/settings.example.json +1 -1
  56. package/server.json +2 -2
@@ -4,9 +4,10 @@
4
4
  import { assertNoSecrets, clip, stripEscapes } from "../dash/render.js";
5
5
  import { clipMultiline, DIFF_MAX_TOTAL_CHARS } from "../diff-preview.js";
6
6
  import { productLabel, readTrail, readTrailSince } from "../trail.js";
7
- import { loadIdentity, loadOrrCard, loadSandboxes, loadShowbackCard, loadWiredHosts, } from "./enrich.js";
7
+ import { loadCertifyCard, loadIdentity, loadOrrCard, loadSandboxes, loadShowbackCard, loadWiredHosts, } from "./enrich.js";
8
8
  import { SHOWBACK_DISCLAIMER, } from "../showback/cost-per-task.js";
9
9
  import { computeDashboard } from "./dashboard.js";
10
+ import { receiptCss } from "./receipt-css.js";
10
11
  function esc(s) {
11
12
  return stripEscapes(s)
12
13
  .replace(/&/g, "&")
@@ -197,6 +198,7 @@ export function buildReceiptModel(sessionId, events, extras) {
197
198
  sandboxes: extras?.sandboxes,
198
199
  wiredHosts: extras?.wiredHosts,
199
200
  orr: extras?.orr,
201
+ certify: extras?.certify,
200
202
  showback: extras?.showback,
201
203
  };
202
204
  }
@@ -215,6 +217,7 @@ export function buildWindowReceiptModel(events, days, extras) {
215
217
  sandboxes: extras?.sandboxes,
216
218
  wiredHosts: extras?.wiredHosts,
217
219
  orr: extras?.orr,
220
+ certify: extras?.certify,
218
221
  showback: extras?.showback,
219
222
  };
220
223
  }
@@ -229,6 +232,7 @@ export function loadReceiptModel(input) {
229
232
  sandboxes: loadSandboxes(input.cwd),
230
233
  wiredHosts: loadWiredHosts(input.cwd),
231
234
  orr: loadOrrCard(input.cwd),
235
+ certify: loadCertifyCard(input.cwd),
232
236
  showback,
233
237
  spend: showback
234
238
  ? {
@@ -261,8 +265,8 @@ function projectMeta(project) {
261
265
  function renderFeed(events, nowMs, live) {
262
266
  if (events.length === 0) {
263
267
  const hint = live
264
- ? "No events yet. Wrap a tool call and this page will refresh."
265
- : "No events yet. Run a wrap, then regenerate this receipt.";
268
+ ? "No events yet — evaluate a tool call with <code>kya wrap</code> and this page will refresh."
269
+ : "No events yet — evaluate a tool call with <code>kya wrap</code>, then regenerate this receipt.";
266
270
  return `<p class="empty">${hint}</p>`;
267
271
  }
268
272
  const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
@@ -541,7 +545,108 @@ function orrPanel(orr, nowMs) {
541
545
  function usdText(usd) {
542
546
  return usd === null ? "USD n/a" : `~$${usd.toFixed(2)}`;
543
547
  }
544
- function showbackPanel(report) {
548
+ /** KPI tile: big tabular number over a small caps label. The tone class is
549
+ * only applied above zero — a zero count never reads as a signal. */
550
+ function kpiTile(label, value, cls = "") {
551
+ const v = typeof value === "number" ? value : esc(value);
552
+ const toned = typeof value === "number" && value > 0 && cls ? ` ${cls}` : "";
553
+ return `<div class="kpi"><span class="kpi-num${toned}">${v}</span><span class="kpi-lab">${label}</span></div>`;
554
+ }
555
+ /**
556
+ * Hero Certify panel: purpose-built rich card for the live Agent Trust
557
+ * Baseline. The section carries the result state (`hero-certify pass|gap|
558
+ * none`) for the accent border; an absent card is a fail-closed neutral
559
+ * state that must never read as a pass.
560
+ */
561
+ function heroCertifyPanel(certify) {
562
+ if (!certify) {
563
+ return `<section class="panel hero-certify none" aria-label="Certify">
564
+ <h2>Certify — Agent Trust Baseline</h2>
565
+ <div class="orrline">
566
+ <span class="pill">not evaluated</span>
567
+ </div>
568
+ <p class="mute small">No live evaluation available — wrap tool calls and run kya certify to build the baseline.</p>
569
+ </section>`;
570
+ }
571
+ const state = certify.result === "pass" ? "pass" : "gap";
572
+ // topGaps arrives severity-ordered from the loader; cap defensively at 5.
573
+ const gaps = certify.topGaps.length === 0
574
+ ? ""
575
+ : `<ul class="rows">
576
+ ${certify.topGaps
577
+ .slice(0, 5)
578
+ .map((g) => ` <li><code>${esc(g.id)}</code> <span class="sev ${esc(g.severity)}">${esc(g.severity)}</span></li>`)
579
+ .join("\n")}
580
+ </ul>`;
581
+ // Pill tones reuse the ORR palette: pass is green, gap is amber.
582
+ return `<section class="panel hero-certify ${state}" aria-label="Certify">
583
+ <h2>Certify — Agent Trust Baseline</h2>
584
+ <div class="orrline">
585
+ <span class="pill orr-${state === "pass" ? "green" : "amber"}">${esc(certify.result)}</span>
586
+ </div>
587
+ <div class="kpi-row">
588
+ ${kpiTile("Pass", certify.pass, "ok")}
589
+ ${kpiTile("Gap", certify.gap, "warn")}
590
+ ${kpiTile("Insufficient", certify.insufficientEvidence, "mute")}
591
+ ${kpiTile("Attested", certify.attested, "ok")}
592
+ </div>
593
+ ${gaps}
594
+ <p class="mute small">live evaluation — window ${certify.windowDays}d, ${certify.trailEvents} trail events · kya certify for the full gap report + signed bundle</p>
595
+ </section>`;
596
+ }
597
+ /** Hero KPI cards: compact, non-interactive rollups (filters live in the
598
+ * Activity zone's chip bar). Numbers keep tabular-nums via .mix-num/.kpi-num. */
599
+ function verdictsHeroCard(c, total) {
600
+ // Base 100% = total events, matching dashboard.ts's verdictMix contract:
601
+ // verdict is a free-form string from untrusted JSONL, so an event may land
602
+ // in no bucket (bogus verdict) while still counting in `never` — only the
603
+ // raw event count keeps every bar inside the base the Overview card uses.
604
+ const pct = (n) => (total > 0 ? Math.round((n / total) * 100) : 0);
605
+ const mixRow = (label, n, fill) => `<div class="mix-row"><span class="mix-lab">${label}</span><span class="mix-bar"><span class="mix-fill ${fill}" style="width:${pct(n)}%"></span></span><span class="mix-num">${n}</span></div>`;
606
+ return `<section class="panel" aria-label="Verdicts">
607
+ <h2>Verdicts</h2>
608
+ <div class="mix">
609
+ ${mixRow("Allow", c.allow, "ok")}
610
+ ${mixRow("Deny", c.deny, "bad")}
611
+ ${mixRow("Review", c.require, "warn")}
612
+ ${mixRow("Never", c.never, "bad")}
613
+ </div>
614
+ </section>`;
615
+ }
616
+ const SPARK_BARS = 7;
617
+ const SPARK_H = 24;
618
+ /**
619
+ * Inline 7-bar sparkline (no JS): the last 7 activity buckets, left-padded
620
+ * with zeros, heights normalized so the max bucket is full height. Empty
621
+ * input renders a flat baseline — heights are guarded, never NaN/Infinity.
622
+ */
623
+ function activitySparkline(activity) {
624
+ const counts = activity.buckets.slice(-SPARK_BARS).map((b) => b.count);
625
+ while (counts.length < SPARK_BARS)
626
+ counts.unshift(0);
627
+ const max = Math.max(0, ...counts);
628
+ const rects = counts
629
+ .map((n, i) => {
630
+ const h = max > 0 ? Math.round((n / max) * SPARK_H) : 0;
631
+ return `<rect x="${i * 10}" y="${SPARK_H - h}" width="8" height="${h}" rx="1"></rect>`;
632
+ })
633
+ .join("");
634
+ const unit = activity.granularity === "hour" ? "hours" : "days";
635
+ return `<svg class="spark" viewBox="0 0 68 ${SPARK_H}" role="img" aria-label="Activity, last ${SPARK_BARS} ${unit}">${rects}<line class="base" x1="0" y1="${SPARK_H - 0.5}" x2="68" y2="${SPARK_H - 0.5}"></line></svg>`;
636
+ }
637
+ function activityHeroCard(agg, db, total) {
638
+ return `<section class="panel" aria-label="Activity">
639
+ <h2>Activity</h2>
640
+ <div class="kpi-row">
641
+ ${kpiTile("Events", total)}
642
+ </div>
643
+ ${activitySparkline(db.activity)}
644
+ <p class="mute small">observe ${agg.modes.observe} · hold ${agg.modes.hold} · offline ${agg.modes.offline} · IDE ${agg.planes.ide} · runtime ${agg.planes.runtime}</p>
645
+ </section>`;
646
+ }
647
+ /** Hero-styled showback: same fields as the old panel (tokens in/out, est.
648
+ * USD, top runs, disclaimer), omitted entirely when no report exists. */
649
+ function showbackHeroCard(report) {
545
650
  if (!report)
546
651
  return "";
547
652
  const topRuns = [...report.perRun]
@@ -556,7 +661,11 @@ ${topRuns
556
661
  </ul>`;
557
662
  return `<section class="panel" aria-label="Showback">
558
663
  <h2>Showback (observe only)</h2>
559
- <p class="line">${report.totalTokensIn} tokens in · ${report.totalTokensOut} tokens out · ${esc(usdText(report.estimatedUsd))}</p>
664
+ <div class="kpi-row">
665
+ ${kpiTile("Tokens in", report.totalTokensIn)}
666
+ ${kpiTile("Tokens out", report.totalTokensOut)}
667
+ ${kpiTile("Est. USD", usdText(report.estimatedUsd))}
668
+ </div>
560
669
  ${runs}
561
670
  <p class="mute small">${esc(SHOWBACK_DISCLAIMER)}</p>
562
671
  </section>`;
@@ -612,6 +721,12 @@ export function renderReceiptHtml(model) {
612
721
  // must never resolve via Object.prototype. The id lets tests extract just
613
722
  // this script. KNOWN is space-delimited so `indexOf(' '+g+' ')` doubles as a
614
723
  // prototype-safe whitelist ('constructor' etc. never match).
724
+ // Filter state persists in the ?f= QUERY param, not the hash: the hash is
725
+ // owned by the pure-CSS :target tabs (#overview/#activity/#system), so a
726
+ // hash-based filter would hide the Activity zone on every chip toggle and
727
+ // every tab click would wipe the filter. Legacy #f= hashes are still parsed
728
+ // on load (read-only); the next save writes the ?f= form and drops the
729
+ // legacy fragment.
615
730
  const filterScript = `<script id="kya-filters">
616
731
  (function(){
617
732
  var feed = document.getElementById('feed');
@@ -622,9 +737,20 @@ export function renderReceiptHtml(model) {
622
737
  var chips = document.querySelectorAll('[data-fgroup]');
623
738
  function parse(){
624
739
  state = Object.create(null);
625
- var h = location.hash;
626
- if (h.indexOf('#f=') !== 0) return;
627
- h.slice(3).split(',').forEach(function(seg){
740
+ var raw = null;
741
+ var s = location.search;
742
+ if (s.indexOf('?') === 0) {
743
+ var params = s.slice(1).split('&');
744
+ for (var i = 0; i < params.length; i++) {
745
+ if (params[i].indexOf('f=') === 0) { raw = params[i].slice(2); break; }
746
+ }
747
+ }
748
+ if (raw === null) {
749
+ var legacy = location.hash;
750
+ if (legacy.indexOf('#f=') === 0) raw = legacy.slice(3);
751
+ }
752
+ if (raw === null) return;
753
+ raw.split(',').forEach(function(seg){
628
754
  var i = seg.indexOf(':');
629
755
  if (i < 1) return;
630
756
  var g = seg.slice(0, i);
@@ -640,8 +766,22 @@ export function renderReceiptHtml(model) {
640
766
  Object.keys(state).forEach(function(g){
641
767
  Object.keys(state[g]).forEach(function(v){ parts.push(g + ':' + encodeURIComponent(v)); });
642
768
  });
643
- var h = parts.length ? '#f=' + parts.join(',') : '';
644
- history.replaceState(null, '', location.pathname + location.search + h);
769
+ // Rebuild the query rather than overwrite it: the live page carries its
770
+ // loopback token as ?t= and must not lose it on a chip toggle.
771
+ var kept = [];
772
+ var s = location.search;
773
+ if (s.indexOf('?') === 0) {
774
+ var params = s.slice(1).split('&');
775
+ for (var i = 0; i < params.length; i++) {
776
+ if (params[i] && params[i].indexOf('f=') !== 0) kept.push(params[i]);
777
+ }
778
+ }
779
+ if (parts.length) kept.push('f=' + parts.join(','));
780
+ var q = kept.length ? '?' + kept.join('&') : '';
781
+ // The tab hash rides along untouched; a legacy #f= fragment is not a tab
782
+ // hash and is dropped now that the state lives in the query.
783
+ var h = location.hash.indexOf('#f=') === 0 ? '' : location.hash;
784
+ history.replaceState(null, '', location.pathname + q + h);
645
785
  }
646
786
  function active(){
647
787
  return Object.keys(state).some(function(g){ return Object.keys(state[g]).length > 0; });
@@ -689,405 +829,71 @@ export function renderReceiptHtml(model) {
689
829
  })();
690
830
  </script>`;
691
831
  const livePill = model.live ? `<span class="live" title="Watching trail.jsonl">Live</span>` : "";
832
+ // Zones never render as bare whitespace: with no panels they get a hint.
833
+ const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
834
+ ? parts.filter(Boolean).join("\n ")
835
+ : `<p class="mute small zone-empty">${empty}</p>`;
836
+ const overviewBody = zoneBody([dashboardPanel(dashboard), sessionsPanel(agg, nowMs), reasonsPanel(agg)], "No activity in this window yet — analytics appear once events land.");
837
+ const systemBody = zoneBody([
838
+ wiredHostsPanel(model.wiredHosts),
839
+ sandboxesPanel(model.sandboxes, nowMs),
840
+ orrPanel(model.orr, nowMs),
841
+ toolsHtml,
842
+ ], "No system state yet — wire a host, configure a sandbox, or run an ORR.");
692
843
  const html = `<!DOCTYPE html>
693
844
  <html lang="en">
694
845
  <head>
695
846
  <meta charset="utf-8"/>
696
847
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
697
848
  <title>${esc(model.title)} - ${esc(model.rangeLabel)}</title>
698
- <style>
699
- :root {
700
- color-scheme: dark;
701
- --bg: #0a0c10;
702
- --fg: #eef2f7;
703
- --mute: #8b95a8;
704
- --line: #1c2330;
705
- --card: #10141c;
706
- --ok: #3ecf8e;
707
- --bad: #ff5d5d;
708
- --warn: #f0b429;
709
- --rail: #2a3344;
710
- --day: #6b7280;
711
- }
712
- @media (prefers-color-scheme: light) {
713
- :root {
714
- color-scheme: light;
715
- --bg: #f3f5f8;
716
- --fg: #0f172a;
717
- --mute: #64748b;
718
- --line: #e2e8f0;
719
- --card: #ffffff;
720
- --ok: #059669;
721
- --bad: #dc2626;
722
- --warn: #d97706;
723
- --rail: #cbd5e1;
724
- --day: #64748b;
725
- }
726
- }
727
- * { box-sizing: border-box; }
728
- body {
729
- margin: 0;
730
- font-family: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
731
- background: var(--bg);
732
- color: var(--fg);
733
- line-height: 1.4;
734
- }
735
- main { max-width: 42rem; margin: 0 auto; padding: 1.35rem 1rem 3rem; }
736
- header.bar {
737
- position: sticky; top: 0; z-index: 3;
738
- background: color-mix(in srgb, var(--bg) 88%, transparent);
739
- backdrop-filter: blur(10px);
740
- padding: 0.85rem 0 0.75rem;
741
- border-bottom: 1px solid var(--line);
742
- margin-bottom: 1rem;
743
- }
744
- .title-row {
745
- display: flex; align-items: baseline; justify-content: space-between;
746
- gap: 0.75rem; flex-wrap: wrap;
747
- }
748
- h1 {
749
- margin: 0; font-size: 1.2rem; font-weight: 600;
750
- letter-spacing: -0.025em;
751
- }
752
- .range { color: var(--mute); font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.5rem; }
753
- .live {
754
- display: inline-flex; align-items: center; gap: 0.35rem;
755
- font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
756
- color: var(--ok); border: 1px solid color-mix(in srgb, var(--ok) 40%, transparent);
757
- border-radius: 999px; padding: 0.12rem 0.5rem;
758
- }
759
- .live::before {
760
- content: ""; width: 0.4rem; height: 0.4rem; border-radius: 50%;
761
- background: var(--ok);
762
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent);
763
- animation: pulse 1.6s ease-in-out infinite;
764
- }
765
- .live.off { color: var(--mute); border-color: var(--line); }
766
- .live.off::before { background: var(--mute); box-shadow: none; animation: none; }
767
- @keyframes pulse {
768
- 0%, 100% { opacity: 1; }
769
- 50% { opacity: 0.45; }
770
- }
771
- @media (prefers-reduced-motion: reduce) {
772
- .live::before { animation: none; }
773
- }
774
- .stats {
775
- display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem;
776
- }
777
- .stat {
778
- font-size: 0.75rem; font-variant-numeric: tabular-nums;
779
- color: var(--mute); background: var(--card);
780
- border: 1px solid var(--line); border-radius: 999px;
781
- padding: 0.2rem 0.6rem;
782
- }
783
- .stat b { color: var(--fg); font-weight: 650; margin-left: 0.25rem; }
784
- .stat.bad b { color: var(--bad); }
785
- .stat.warn b { color: var(--warn); }
786
- .stat.ok b { color: var(--ok); }
787
- button.stat {
788
- font-family: inherit; cursor: pointer;
789
- appearance: none; -webkit-appearance: none;
790
- }
791
- button.stat:hover { border-color: var(--mute); }
792
- .stat[aria-pressed="true"] {
793
- color: var(--fg);
794
- border-color: color-mix(in srgb, var(--ok) 55%, var(--line));
795
- background: color-mix(in srgb, var(--ok) 12%, var(--card));
796
- }
797
- .stat[hidden] { display: none; }
798
- .clear-filters { color: var(--mute); font-size: 0.72rem; }
799
- /* Analytics dashboard: 2×2 card grid, collapses to one column on narrow. */
800
- .db-grid {
801
- display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem 1.1rem;
802
- }
803
- @media (max-width: 700px) {
804
- .db-grid { grid-template-columns: 1fr; }
805
- }
806
- .db-card { min-width: 0; }
807
- .db-card h3 {
808
- margin: 0 0 0.4rem; font-size: 0.68rem; font-weight: 700;
809
- letter-spacing: 0.06em; text-transform: uppercase; color: var(--mute);
810
- }
811
- .db-card h3 .mute { text-transform: none; letter-spacing: 0.02em; font-weight: 600; }
812
- .db-card .sub {
813
- margin: 0.35rem 0 0.15rem; font-size: 0.66rem; font-weight: 700;
814
- letter-spacing: 0.05em; text-transform: uppercase; color: var(--day);
815
- }
816
- /* Filter-linked bar row: block variant of .stat's hover/active contract. */
817
- .db-item {
818
- display: grid; grid-template-columns: 6.5rem 1fr 2.4rem;
819
- gap: 0.5rem; align-items: center; width: 100%;
820
- font-family: inherit; font-size: 0.76rem; font-variant-numeric: tabular-nums;
821
- color: var(--mute); text-align: left;
822
- background: transparent; border: 1px solid transparent; border-radius: 8px;
823
- padding: 0.14rem 0.3rem; cursor: pointer;
824
- appearance: none; -webkit-appearance: none;
825
- }
826
- .db-item:hover { border-color: var(--line); background: color-mix(in srgb, var(--bg) 55%, var(--card)); }
827
- .db-item[aria-pressed="true"] {
828
- color: var(--fg);
829
- border-color: color-mix(in srgb, var(--ok) 55%, var(--line));
830
- background: color-mix(in srgb, var(--ok) 12%, var(--card));
831
- }
832
- .db-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
833
- .db-bar {
834
- height: 0.55rem; border-radius: 4px; overflow: hidden;
835
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
836
- }
837
- .db-fill { display: block; height: 100%; border-radius: 4px; }
838
- .db-fill.ok { background: var(--ok); }
839
- .db-fill.warn { background: var(--warn); }
840
- .db-fill.bad { background: var(--bad); }
841
- .db-num { text-align: right; color: var(--fg); }
842
- /* Vertical mini-bar timeline (not clickable — no time filter exists). */
843
- .db-tl { display: flex; align-items: stretch; gap: 2px; height: 4.6rem; }
844
- .db-tl .col {
845
- flex: 1 1 0; min-width: 0; display: flex; flex-direction: column;
846
- align-items: center; justify-content: flex-end; gap: 0.15rem;
847
- }
848
- .db-vbar {
849
- width: 100%; min-height: 2px; border-radius: 2px 2px 0 0;
850
- background: color-mix(in srgb, var(--ok) 75%, var(--mute));
851
- }
852
- .db-tl .lab {
853
- font-size: 0.52rem; color: var(--mute); white-space: nowrap;
854
- overflow: hidden; max-width: 100%; font-variant-numeric: tabular-nums;
855
- }
856
- /* utility: must beat .ev's display:grid (same specificity, later rule would win) */
857
- .filtered-out { display: none !important; }
858
- .identity {
859
- margin-top: 0.45rem; color: var(--mute); font-size: 0.78rem;
860
- word-break: break-word;
861
- }
862
- .modeb {
863
- display: inline-block; font-size: 0.6rem; font-weight: 700;
864
- color: var(--mute); border: 1px solid var(--line); border-radius: 4px;
865
- padding: 0 0.28rem; line-height: 1.1rem; vertical-align: baseline;
866
- }
867
- .panel {
868
- background: var(--card); border: 1px solid var(--line);
869
- border-radius: 12px; padding: 0.7rem 0.9rem 0.8rem; margin-bottom: 1rem;
870
- }
871
- .panel h2 {
872
- margin: 0 0 0.5rem; font-size: 0.72rem; font-weight: 700;
873
- letter-spacing: 0.07em; text-transform: uppercase; color: var(--day);
874
- }
875
- .panel .sub { margin: 0 0 0.45rem; color: var(--mute); font-size: 0.8rem; }
876
- .panel .line { margin: 0.25rem 0; font-size: 0.84rem; word-break: break-word; }
877
- .panel .mute { color: var(--mute); }
878
- .panel .small { font-size: 0.74rem; }
879
- .rows {
880
- list-style: none; margin: 0; padding: 0;
881
- display: flex; flex-direction: column; gap: 0.3rem;
882
- font-size: 0.82rem;
883
- }
884
- .rows li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.45rem; min-width: 0; }
885
- .rows li.mute { color: var(--mute); }
886
- .rows .sid, .rows code {
887
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
888
- font-size: 0.78rem; word-break: break-all;
889
- }
890
- .rows .cnt { color: var(--mute); font-size: 0.76rem; }
891
- .rows .rel { margin-left: auto; color: var(--mute); font-size: 0.74rem; white-space: nowrap; }
892
- .rows .hint {
893
- font-size: 0.72rem; border-bottom: 1px dotted var(--mute); cursor: help;
894
- }
895
- .wdot {
896
- width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none;
897
- align-self: center; background: var(--mute);
898
- }
899
- .wdot.allow { background: var(--ok); }
900
- .wdot.hold { background: var(--warn); }
901
- .wdot.deny, .wdot.never { background: var(--bad); }
902
- .chiprow { display: flex; flex-wrap: wrap; gap: 0.35rem; }
903
- .chip {
904
- font-size: 0.76rem; color: var(--mute);
905
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
906
- border: 1px solid var(--line); border-radius: 999px; padding: 0.15rem 0.55rem;
907
- }
908
- .chip code {
909
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace;
910
- color: var(--fg); font-size: 0.74rem;
911
- }
912
- .tbl { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
913
- .tbl th {
914
- text-align: left; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.06em;
915
- text-transform: uppercase; color: var(--mute); padding: 0.15rem 0.5rem 0.3rem 0;
916
- }
917
- .tbl td { padding: 0.2rem 0.5rem 0.2rem 0; border-top: 1px solid var(--line); }
918
- .tbl code {
919
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace; font-size: 0.76rem;
920
- }
921
- .pill {
922
- display: inline-block; font-size: 0.7rem; font-weight: 700;
923
- letter-spacing: 0.04em; text-transform: uppercase;
924
- border-radius: 999px; padding: 0.1rem 0.55rem;
925
- border: 1px solid var(--line); color: var(--mute);
926
- }
927
- .pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
928
- .pill.orr-green { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
929
- .pill.orr-amber { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
930
- .pill.orr-red { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
931
- .orrline { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.55rem; }
932
- .orrline .disp { font-size: 0.84rem; font-weight: 600; }
933
- .alert {
934
- display: flex; flex-wrap: wrap; gap: 0.45rem 0.75rem; align-items: baseline;
935
- background: color-mix(in srgb, var(--bad) 12%, var(--card));
936
- border: 1px solid color-mix(in srgb, var(--bad) 35%, var(--line));
937
- border-radius: 10px; padding: 0.65rem 0.85rem; margin-bottom: 1rem;
938
- font-size: 0.85rem;
939
- }
940
- .alert strong { color: var(--bad); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
941
- .alert code {
942
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
943
- font-size: 0.78rem;
944
- }
945
- .feed {
946
- background: var(--card);
947
- border: 1px solid var(--line);
948
- border-radius: 12px;
949
- overflow: hidden;
950
- }
951
- .day {
952
- padding: 0.55rem 0.9rem 0.35rem;
953
- font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em;
954
- text-transform: uppercase; color: var(--day);
955
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
956
- border-bottom: 1px solid var(--line);
957
- }
958
- .ev {
959
- display: grid; grid-template-columns: 1.1rem 1fr; gap: 0;
960
- border-bottom: 1px solid var(--line);
961
- }
962
- .ev:last-child { border-bottom: none; }
963
- .rail {
964
- position: relative;
965
- display: flex; justify-content: center;
966
- padding-top: 1.05rem;
967
- }
968
- .rail::before {
969
- content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
970
- width: 1px; background: var(--rail); transform: translateX(-50%);
971
- }
972
- .tick {
973
- width: 0.55rem; height: 0.55rem; border-radius: 50%;
974
- background: var(--mute); position: relative; z-index: 1;
975
- box-shadow: 0 0 0 3px var(--card);
976
- }
977
- .ev.ok .tick { background: var(--ok); }
978
- .ev.bad .tick { background: var(--bad); }
979
- .ev.warn .tick { background: var(--warn); }
980
- .ev.never { background: color-mix(in srgb, var(--bad) 7%, transparent); }
981
- .main { padding: 0.7rem 0.85rem 0.7rem 0.35rem; min-width: 0; }
982
- .top {
983
- display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.55rem;
984
- }
985
- @media (max-width: 480px) {
986
- .when { flex-basis: 100%; margin-left: 0; margin-top: 0.1rem; }
987
- }
988
- .verdict {
989
- font-size: 0.68rem; font-weight: 750; letter-spacing: 0.05em;
990
- min-width: 2.6rem;
991
- }
992
- .ev.ok .verdict { color: var(--ok); }
993
- .ev.bad .verdict { color: var(--bad); }
994
- .ev.warn .verdict { color: var(--warn); }
995
- .tool {
996
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
997
- font-size: 0.84rem; word-break: break-word; flex: 1 1 auto;
998
- }
999
- .when {
1000
- margin-left: auto; color: var(--mute); font-size: 0.72rem;
1001
- font-variant-numeric: tabular-nums; white-space: nowrap;
1002
- }
1003
- .when .rel { opacity: 0.85; margin-left: 0.35rem; }
1004
- .summary {
1005
- margin-top: 0.2rem;
1006
- font-size: 0.84rem;
1007
- color: var(--fg);
1008
- word-break: break-word;
1009
- }
1010
- details.diff {
1011
- margin-top: 0.35rem;
1012
- border: 1px solid var(--line);
1013
- border-radius: 8px;
1014
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
1015
- overflow: hidden;
1016
- }
1017
- details.diff > summary {
1018
- cursor: pointer;
1019
- list-style: none;
1020
- padding: 0.35rem 0.55rem;
1021
- font-size: 0.72rem;
1022
- font-weight: 650;
1023
- letter-spacing: 0.04em;
1024
- text-transform: uppercase;
1025
- color: var(--mute);
1026
- }
1027
- details.diff > summary::-webkit-details-marker { display: none; }
1028
- details.diff pre {
1029
- margin: 0;
1030
- padding: 0.45rem 0.55rem 0.6rem;
1031
- border-top: 1px solid var(--line);
1032
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace;
1033
- font-size: 0.72rem;
1034
- line-height: 1.35;
1035
- white-space: pre-wrap;
1036
- word-break: break-word;
1037
- color: var(--fg);
1038
- }
1039
- .meta {
1040
- margin-top: 0.18rem; color: var(--mute); font-size: 0.76rem;
1041
- display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: baseline;
1042
- }
1043
- .dot { opacity: 0.55; }
1044
- .empty { margin: 0; padding: 1.25rem 1rem; color: var(--mute); font-size: 0.9rem; }
1045
- .tools {
1046
- margin-top: 1.1rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
1047
- }
1048
- .tools .label {
1049
- font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em;
1050
- text-transform: uppercase; color: var(--mute); margin-right: 0.25rem;
1051
- }
1052
- .tools code {
1053
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace;
1054
- font-size: 0.72rem; color: var(--mute);
1055
- background: var(--card); border: 1px solid var(--line);
1056
- border-radius: 6px; padding: 0.18rem 0.4rem;
1057
- }
1058
- </style>
849
+ <style>${receiptCss()}</style>
1059
850
  </head>
1060
851
  <body>
1061
852
  <main>
1062
- <header class="bar">
1063
- <div class="title-row">
1064
- <h1>${esc(model.title)}</h1>
1065
- <div class="range">${esc(model.rangeLabel)}${livePill}</div>
1066
- </div>
1067
- ${identityLine(model.identity)}
1068
- <div class="stats" aria-label="Counts">
1069
- <button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
1070
- <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
1071
- <button type="button" class="stat warn" data-fgroup="verdict" data-fvalue="REQUIRE_APPROVE" aria-pressed="false" title="Filter: Review">Review<b>${c.require}</b></button>
1072
- <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
1073
- ${chips.modes}${chips.planes}
1074
- <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>
1075
- </div>
1076
- ${chips.products}
1077
- ${chips.projects}
1078
- </header>
853
+ <div class="topstick">
854
+ <header class="bar">
855
+ <div class="title-row">
856
+ <h1>${esc(model.title)}</h1>
857
+ <div class="range">${esc(model.rangeLabel)}${livePill}</div>
858
+ </div>
859
+ ${identityLine(model.identity)}
860
+ </header>
861
+ <nav class="tabs" aria-label="Report sections">
862
+ <a class="tab" id="tab-overview" href="#overview">Overview</a>
863
+ <a class="tab" id="tab-activity" href="#activity">Activity</a>
864
+ <a class="tab" id="tab-system" href="#system">System</a>
865
+ </nav>
866
+ </div>
1079
867
  ${blockedBanner}
1080
- ${dashboardPanel(dashboard)}
1081
- <section class="feed" id="feed" aria-label="Activity feed">
868
+ <div class="hero">
869
+ ${heroCertifyPanel(model.certify)}
870
+ ${verdictsHeroCard(c, events.length)}
871
+ ${activityHeroCard(agg, dashboard, events.length)}
872
+ ${showbackHeroCard(model.showback)}
873
+ </div>
874
+ <section class="zone" id="overview" aria-labelledby="tab-overview">
875
+ ${overviewBody}
876
+ </section>
877
+ <section class="zone" id="activity" aria-labelledby="tab-activity">
878
+ <div class="feed-head">
879
+ <div class="stats" aria-label="Counts">
880
+ <button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
881
+ <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
882
+ <button type="button" class="stat warn" data-fgroup="verdict" data-fvalue="REQUIRE_APPROVE" aria-pressed="false" title="Filter: Review">Review<b>${c.require}</b></button>
883
+ <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
884
+ ${chips.modes}${chips.planes}
885
+ <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>
886
+ </div>
887
+ ${chips.products}
888
+ ${chips.projects}
889
+ </div>
890
+ <section class="feed" id="feed" aria-label="Activity feed">
1082
891
  ${renderFeed(events, nowMs, model.live)}
892
+ </section>
893
+ </section>
894
+ <section class="zone" id="system" aria-labelledby="tab-system">
895
+ ${systemBody}
1083
896
  </section>
1084
- ${sessionsPanel(agg, nowMs)}
1085
- ${reasonsPanel(agg)}
1086
- ${wiredHostsPanel(model.wiredHosts)}
1087
- ${sandboxesPanel(model.sandboxes, nowMs)}
1088
- ${orrPanel(model.orr, nowMs)}
1089
- ${showbackPanel(model.showback)}
1090
- ${toolsHtml}
1091
897
  </main>
1092
898
  ${filterScript}
1093
899
  ${liveScript}
@@ -1137,6 +943,17 @@ export function renderReceiptMarkdown(model) {
1137
943
  if (agg.projects.length >= 2) {
1138
944
  lines.push("## Projects", ...agg.projects.map((p) => `- ${mdText(p.label)} × ${p.count}`), "");
1139
945
  }
946
+ if (model.certify) {
947
+ const c = model.certify;
948
+ lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
949
+ if (c.topGaps.length > 0) {
950
+ lines.push("Top gaps:");
951
+ for (const g of c.topGaps) {
952
+ lines.push(`- \`${mdInline(g.id)}\` — ${mdText(g.severity)}`);
953
+ }
954
+ }
955
+ lines.push(`live evaluation — window ${c.windowDays}d, ${c.trailEvents} trail events · kya certify for the full gap report + signed bundle`, "");
956
+ }
1140
957
  const db = computeDashboard(model.events);
1141
958
  if (db.verdictMix.total > 0) {
1142
959
  const pct = (n) => Math.round((n / db.verdictMix.total) * 100);