@shield-agent/kya 0.32.0 → 0.32.3

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.
@@ -1320,7 +1320,7 @@ function kpiTile(label, value, cls = "") {
1320
1320
  */
1321
1321
  function heroCertifyPanel(certify) {
1322
1322
  if (!certify) {
1323
- return `<section class="panel hero-certify none" aria-label="Certify">
1323
+ return `<section class="panel hero-certify none" id="frag-hero-certify" aria-label="Certify">
1324
1324
  <h2>Certify - Agent Trust Baseline</h2>
1325
1325
  <div class="orrline">
1326
1326
  <span class="pill">not evaluated</span>
@@ -1373,7 +1373,7 @@ ${certify.topGaps
1373
1373
  <span class="mute small">or run <code>kya certify --open</code> for the signed bundle</span>
1374
1374
  </div>`;
1375
1375
  // Pill tones reuse the ORR palette: pass is green, gap is amber.
1376
- return `<section class="panel hero-certify ${state}" aria-label="Certify">
1376
+ return `<section class="panel hero-certify ${state}" id="frag-hero-certify" aria-label="Certify">
1377
1377
  <h2>Certify - Agent Trust Baseline</h2>
1378
1378
  <div class="orrline">
1379
1379
  <span class="pill orr-${state === "pass" ? "green" : "amber"}">${esc(certify.result)}</span>
@@ -1852,6 +1852,63 @@ function paginationBar(total, page, pages) {
1852
1852
  }
1853
1853
  return `<div class="pagination-bar">\n ${prev}\n ${numbers.join("\n ")}\n ${next}\n <span class="mute">Page ${page} of ${pages} · ${total} event${total === 1 ? "" : "s"}</span>\n</div>`;
1854
1854
  }
1855
+ /** Zones never render as bare whitespace: with no panels they get a hint. */
1856
+ const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
1857
+ ? parts.filter(Boolean).join("\n ")
1858
+ : `<p class="mute small zone-empty">${empty}</p>`;
1859
+ /** Inner markup of the Activity counts bar (verdict buttons + mode/plane chips). */
1860
+ function statCountsInner(c, chips) {
1861
+ return `<button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
1862
+ <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
1863
+ <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>
1864
+ <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
1865
+ ${chips.modes}${chips.planes}
1866
+ <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>`;
1867
+ }
1868
+ /**
1869
+ * Live-refresh fragments: the containers POST /fragments re-renders so the
1870
+ * report updates in place instead of reloading the whole page.
1871
+ * renderReceiptHtml builds the initial page through the same helpers, so
1872
+ * first paint and refreshes cannot drift apart.
1873
+ */
1874
+ export function buildLiveFragments(model, livePaged) {
1875
+ const nowMs = Date.now();
1876
+ const events = normalizeTrailEvents(model.events);
1877
+ const c = countVerdicts(events);
1878
+ const agg = aggregateEvents(events);
1879
+ const chips = statChips(agg);
1880
+ const dashboard = computeDashboard(events);
1881
+ const changes = buildChangesModel(events);
1882
+ return {
1883
+ fragments: {
1884
+ statCounts: statCountsInner(c, chips),
1885
+ chipProducts: chips.products,
1886
+ chipProjects: chips.projects,
1887
+ chipServers: chips.servers,
1888
+ heroCertify: heroCertifyPanel(model.certify),
1889
+ heroTiles: [
1890
+ verdictsHeroCard(c, events.length),
1891
+ activityHeroCard(agg, dashboard, events.length),
1892
+ gatewayHeroCard(model.gate, nowMs),
1893
+ importHeroCard(model.imports, nowMs, livePaged),
1894
+ discoveryHeroCard(model.discovery, nowMs, livePaged),
1895
+ investigateHeroCard(model.investigate, nowMs, livePaged),
1896
+ showbackHeroCard(model.showback, events),
1897
+ ].join("\n "),
1898
+ overviewPanels: zoneBody([dashboardPanel(dashboard), sessionsPanel(agg, nowMs, changes), reasonsPanel(agg)], "No activity in this window yet - analytics appear once events land."),
1899
+ certifyBody: zoneBody([model.certify ? certifyDetailPanel(model.certify.requirements) : ""], "No live evaluation yet - wrap tool calls and run kya certify to build the baseline."),
1900
+ orrPanel: orrPanel(model.orr, nowMs, livePaged),
1901
+ changesList: changesPanel(changes, nowMs),
1902
+ },
1903
+ nav: {
1904
+ overview: events.length,
1905
+ activity: events.length,
1906
+ changes: changes.fileCount,
1907
+ certify: model.certify?.gap ?? null,
1908
+ orr: model.orr ? model.orr.scorecards.fail : null,
1909
+ },
1910
+ };
1911
+ }
1855
1912
  export function renderReceiptHtml(model) {
1856
1913
  const nowMs = Date.now();
1857
1914
  const events = normalizeTrailEvents(model.events);
@@ -1871,11 +1928,12 @@ export function renderReceiptHtml(model) {
1871
1928
  page: 1,
1872
1929
  pages: 1,
1873
1930
  };
1874
- const c = countVerdicts(events);
1875
1931
  const agg = aggregateEvents(events);
1876
1932
  const chips = statChips(agg);
1877
- const dashboard = computeDashboard(events);
1878
1933
  const changes = buildChangesModel(events);
1934
+ // Live-refresh fragments render the initial page AND the POST /fragments
1935
+ // refresh, so the two can never drift apart.
1936
+ const fr = buildLiveFragments(model, livePaged);
1879
1937
  const blocked = events.filter((e) => e.neverEvent || e.reasonCode === "NEVER_EVENT");
1880
1938
  const tools = model.mcpSeen && model.mcpSeen.length > 0
1881
1939
  ? model.mcpSeen.map((m) => m.toolId)
@@ -1894,19 +1952,8 @@ export function renderReceiptHtml(model) {
1894
1952
  </footer>`;
1895
1953
  // liveToken is minted base64url by the live server - safe in a JS string
1896
1954
  // literal; static renders omit it. No post-hoc html.replace (spoofable).
1897
- const eventsUrl = model.liveToken ? `/events?t=${encodeURIComponent(model.liveToken)}` : "/events";
1898
- const liveScript = model.live
1899
- ? `<script>
1900
- (function(){
1901
- var pill = document.querySelector('.live');
1902
- var es = new EventSource('${eventsUrl}');
1903
- es.onmessage = function(){ location.reload(); };
1904
- es.onerror = function(){
1905
- if (pill) { pill.textContent = 'Offline'; pill.classList.add('off'); }
1906
- };
1907
- })();
1908
- </script>`
1909
- : "";
1955
+ // Live updates ride the filter script's EventSource + /fragments refresh;
1956
+ // there is deliberately no page-level reload-on-change script.
1910
1957
  // Client-side chip filters, pagination, and search: only emitted for live
1911
1958
  // reports (liveToken present). Static receipts render a separate BM25-only
1912
1959
  // search script below. State maps are null-prototype: attacker-controlled
@@ -1932,8 +1979,8 @@ export function renderReceiptHtml(model) {
1932
1979
  var page = 1;
1933
1980
  var q = '';
1934
1981
  var LIVE = !!(document.querySelectorAll && document.querySelectorAll('main[data-live-token]').length);
1935
- var clearBtn = document.getElementById('clear-filters');
1936
- var chips = document.querySelectorAll('[data-fgroup]');
1982
+ var stopped = false;
1983
+ var pill = null;
1937
1984
  function readToken(){
1938
1985
  var s = location.search;
1939
1986
  if (s.indexOf('?') !== 0) return '';
@@ -1945,6 +1992,24 @@ export function renderReceiptHtml(model) {
1945
1992
  }
1946
1993
  return '';
1947
1994
  }
1995
+ // 401 on any live request means the report server restarted and this page's
1996
+ // token is stale. Stop refreshing and tell the user how to get a fresh link
1997
+ // instead of looping on a dead token.
1998
+ function showRestartBanner(){
1999
+ if (stopped) return;
2000
+ stopped = true;
2001
+ if (pill) { pill.textContent = 'Offline'; pill.classList.add('off'); }
2002
+ try {
2003
+ var main = document.querySelector('main');
2004
+ if (!main || document.getElementById('kya-restart-banner') || !document.createElement) return;
2005
+ var div = document.createElement('div');
2006
+ div.id = 'kya-restart-banner';
2007
+ div.className = 'alert';
2008
+ div.setAttribute('role', 'status');
2009
+ div.textContent = 'Report server restarted. Re-run kya receipt --open (or kya start) for a fresh link.';
2010
+ main.insertBefore(div, main.firstChild);
2011
+ } catch (e) { /* banner is best-effort */ }
2012
+ }
1948
2013
  function parse(){
1949
2014
  state = Object.create(null);
1950
2015
  page = 1;
@@ -2047,11 +2112,12 @@ export function renderReceiptHtml(model) {
2047
2112
  group.classList.toggle('filtered-out', on && !vis);
2048
2113
  });
2049
2114
  }
2050
- chips.forEach(function(chip){
2115
+ document.querySelectorAll('[data-fgroup]').forEach(function(chip){
2051
2116
  var pressed = !!(state[chip.dataset.fgroup] && state[chip.dataset.fgroup][chip.dataset.fvalue]);
2052
2117
  chip.setAttribute('aria-pressed', pressed ? 'true' : 'false');
2053
2118
  });
2054
- if (clearBtn) clearBtn.hidden = !active();
2119
+ var cb = document.getElementById('clear-filters');
2120
+ if (cb) cb.hidden = !active();
2055
2121
  }
2056
2122
  function updateMeta(total){
2057
2123
  if (!searchMeta) return;
@@ -2074,7 +2140,10 @@ export function renderReceiptHtml(model) {
2074
2140
  headers: { 'Content-Type': 'application/json' },
2075
2141
  body: JSON.stringify({ filters: serverFilters, page: page, q: q })
2076
2142
  })
2077
- .then(function(r){ return r.json(); })
2143
+ .then(function(r){
2144
+ if (r.status === 401) { showRestartBanner(); throw new Error('unauthorized'); }
2145
+ return r.json();
2146
+ })
2078
2147
  .then(function(data){
2079
2148
  if (feed && data.feedHtml) feed.innerHTML = data.feedHtml;
2080
2149
  if (bar && data.paginationHtml) bar.innerHTML = data.paginationHtml;
@@ -2082,19 +2151,31 @@ export function renderReceiptHtml(model) {
2082
2151
  })
2083
2152
  .catch(function(err){ console.error('KYA feed update failed', err); });
2084
2153
  }
2085
- chips.forEach(function(chip){
2086
- chip.addEventListener('click', function(){
2087
- var g = chip.dataset.fgroup, v = chip.dataset.fvalue;
2088
- if (state[g] && state[g][v]) {
2089
- delete state[g][v];
2090
- if (!Object.keys(state[g]).length) delete state[g];
2091
- } else {
2092
- (state[g] || (state[g] = Object.create(null)))[v] = true;
2093
- }
2094
- page = 1;
2154
+ // Filter chips are delegated on document: live refresh swaps chip containers
2155
+ // innerHTML, so per-element bindings would die on the first update. The
2156
+ // pressed state is reapplied by apply() from the query-param state.
2157
+ document.addEventListener('click', function(e){
2158
+ var t = e.target;
2159
+ if (!t || !t.closest) return;
2160
+ if (t.closest('#clear-filters')) {
2161
+ state = Object.create(null); page = 1; q = '';
2162
+ if (searchInput) searchInput.value = '';
2095
2163
  save(); apply();
2096
2164
  if (LIVE) feedUpdate();
2097
- });
2165
+ return;
2166
+ }
2167
+ var chip = t.closest('[data-fgroup]');
2168
+ if (!chip) return;
2169
+ var g = chip.dataset.fgroup, v = chip.dataset.fvalue;
2170
+ if (state[g] && state[g][v]) {
2171
+ delete state[g][v];
2172
+ if (!Object.keys(state[g]).length) delete state[g];
2173
+ } else {
2174
+ (state[g] || (state[g] = Object.create(null)))[v] = true;
2175
+ }
2176
+ page = 1;
2177
+ save(); apply();
2178
+ if (LIVE) feedUpdate();
2098
2179
  });
2099
2180
  if (LIVE && bar) {
2100
2181
  bar.addEventListener('click', function(e){
@@ -2121,10 +2202,123 @@ export function renderReceiptHtml(model) {
2121
2202
  if (LIVE) debounce = setTimeout(feedUpdate, 120);
2122
2203
  });
2123
2204
  }
2124
- if (clearBtn) clearBtn.addEventListener('click', function(){ state = Object.create(null); page = 1; q = ''; if (searchInput) searchInput.value = ''; save(); apply(); if (LIVE) feedUpdate(); });
2125
2205
  parse(); apply();
2126
2206
  if (searchInput) searchInput.value = q;
2127
2207
  if (LIVE && (active() || page !== 1)) feedUpdate();
2208
+
2209
+ // Live refresh: SSE says what changed, then only the affected containers
2210
+ // re-render in place. No full-page reloads, so scroll, focus, and filter
2211
+ // state survive and new rows stream into the feed on page 1.
2212
+ if (LIVE) {
2213
+ pill = document.querySelector('.live');
2214
+ var dirty = false;
2215
+ var lastFrag = 0;
2216
+ var fragTimer = 0;
2217
+ var sseDebounce = 0;
2218
+ function flash(el){
2219
+ el.classList.remove('frag-flash');
2220
+ void el.offsetWidth;
2221
+ el.classList.add('frag-flash');
2222
+ }
2223
+ function updateNavCounts(nav){
2224
+ Object.keys(nav).forEach(function(k){
2225
+ var link = document.getElementById('tab-' + k);
2226
+ if (!link) return;
2227
+ var span = link.querySelector('.nav-count');
2228
+ if (nav[k] === null || nav[k] === undefined) {
2229
+ if (span) span.parentNode.removeChild(span);
2230
+ return;
2231
+ }
2232
+ if (!span) {
2233
+ span = document.createElement('span');
2234
+ span.className = 'nav-count';
2235
+ link.appendChild(span);
2236
+ }
2237
+ span.textContent = String(nav[k]);
2238
+ });
2239
+ }
2240
+ var FRAG_TARGETS = {
2241
+ statCounts: 'stat-counts',
2242
+ chipProducts: 'chip-products',
2243
+ chipProjects: 'chip-projects',
2244
+ chipServers: 'chip-servers',
2245
+ heroCertify: 'frag-hero-certify',
2246
+ heroTiles: 'frag-hero-tiles',
2247
+ overviewPanels: 'frag-overview-panels',
2248
+ certifyBody: 'frag-certify',
2249
+ orrPanel: 'frag-orr',
2250
+ changesList: 'changes-list'
2251
+ };
2252
+ function loadFragments(){
2253
+ lastFrag = Date.now();
2254
+ fetch('/fragments?t=' + encodeURIComponent(readToken()), {
2255
+ method: 'POST',
2256
+ headers: { 'Content-Type': 'application/json' },
2257
+ body: '{}'
2258
+ })
2259
+ .then(function(r){
2260
+ if (r.status === 401) { showRestartBanner(); throw new Error('unauthorized'); }
2261
+ if (!r.ok) throw new Error('HTTP ' + r.status);
2262
+ return r.json();
2263
+ })
2264
+ .then(function(data){
2265
+ if (!data || !data.ok || !data.fragments) return;
2266
+ Object.keys(FRAG_TARGETS).forEach(function(k){
2267
+ var el = document.getElementById(FRAG_TARGETS[k]);
2268
+ if (!el || typeof data.fragments[k] !== 'string') return;
2269
+ if (el.innerHTML !== data.fragments[k]) {
2270
+ el.innerHTML = data.fragments[k];
2271
+ flash(el);
2272
+ }
2273
+ });
2274
+ // Fresh chip HTML arrives unpressed: reapply from the query-param state.
2275
+ apply();
2276
+ if (data.nav) updateNavCounts(data.nav);
2277
+ })
2278
+ .catch(function(err){ if (!stopped) console.error('KYA fragments update failed', err); });
2279
+ }
2280
+ function scheduleFragments(){
2281
+ if (fragTimer || stopped) return;
2282
+ var wait = Math.max(0, 2000 - (Date.now() - lastFrag));
2283
+ fragTimer = setTimeout(function(){
2284
+ fragTimer = 0;
2285
+ if (!dirty || document.hidden) return;
2286
+ dirty = false;
2287
+ loadFragments();
2288
+ }, wait);
2289
+ }
2290
+ function refresh(){
2291
+ if (stopped) return;
2292
+ if (document.hidden) { dirty = true; return; }
2293
+ dirty = false;
2294
+ feedUpdate();
2295
+ scheduleFragments();
2296
+ }
2297
+ // EventSource is absent in DOM shims and very old engines; the page still
2298
+ // works, it just does not stream updates.
2299
+ if (typeof EventSource === 'function') {
2300
+ var es = new EventSource('/events?t=' + encodeURIComponent(readToken()));
2301
+ es.onmessage = function(){
2302
+ if (stopped) return;
2303
+ dirty = true;
2304
+ clearTimeout(sseDebounce);
2305
+ sseDebounce = setTimeout(refresh, 300);
2306
+ };
2307
+ es.onerror = function(){
2308
+ // EventSource retries on its own; the pill reflects the outage and a
2309
+ // 401 on the next real request surfaces the restart banner.
2310
+ if (pill) { pill.textContent = 'Offline'; pill.classList.add('off'); }
2311
+ };
2312
+ }
2313
+ if (document.addEventListener) {
2314
+ document.addEventListener('visibilitychange', function(){
2315
+ if (!document.hidden && dirty && !stopped) {
2316
+ clearTimeout(sseDebounce);
2317
+ sseDebounce = setTimeout(refresh, 100);
2318
+ }
2319
+ });
2320
+ }
2321
+ }
2128
2322
  })();
2129
2323
  </script>`
2130
2324
  : "";
@@ -2300,7 +2494,15 @@ export function renderReceiptHtml(model) {
2300
2494
  body: JSON.stringify(body)
2301
2495
  });
2302
2496
  }
2303
- function parseJson(res){ return res.json(); }
2497
+ function parseJson(res){
2498
+ if (!res.ok) {
2499
+ var msg = res.status === 401
2500
+ ? 'Session expired or unauthorized (401). Reload the report from kya start, then try again.'
2501
+ : 'Request failed with HTTP ' + res.status;
2502
+ throw new Error(msg);
2503
+ }
2504
+ return res.json();
2505
+ }
2304
2506
 
2305
2507
  var form = document.getElementById('gate-play-form');
2306
2508
  if (form) {
@@ -2359,6 +2561,23 @@ export function renderReceiptHtml(model) {
2359
2561
  });
2360
2562
  });
2361
2563
 
2564
+ // Success feedback must survive the confirmation reload: the result div is
2565
+ // server-rendered empty on the fresh page, so without this the user sees a
2566
+ // blip for 800 ms and then an apparently unchanged page.
2567
+ function persistResult(action, msg){
2568
+ try { sessionStorage.setItem('kya:action-result', JSON.stringify({ a: action, m: msg })); } catch (e) {}
2569
+ }
2570
+ (function showPersistedResult(){
2571
+ var raw;
2572
+ try { raw = sessionStorage.getItem('kya:action-result'); sessionStorage.removeItem('kya:action-result'); } catch (e) { return; }
2573
+ if (!raw) return;
2574
+ var data;
2575
+ try { data = JSON.parse(raw); } catch (e) { return; }
2576
+ var el = document.getElementById('report-action-result-' + data.a)
2577
+ || (data.a === 'import' ? document.getElementById('import-result') : null);
2578
+ if (el && typeof data.m === 'string') setResult(el, '<p>' + esc(data.m) + '</p>', false);
2579
+ })();
2580
+
2362
2581
  document.querySelectorAll('[data-report-action]').forEach(function(btn){
2363
2582
  btn.addEventListener('click', function(){
2364
2583
  var action = btn.getAttribute('data-report-action');
@@ -2380,7 +2599,8 @@ export function renderReceiptHtml(model) {
2380
2599
  if (action === 'otel-test') {
2381
2600
  btn.disabled = false;
2382
2601
  } else {
2383
- setTimeout(function(){ location.reload(); }, 800);
2602
+ persistResult(action, msg);
2603
+ setTimeout(function(){ location.reload(); }, 1500);
2384
2604
  }
2385
2605
  } else {
2386
2606
  setResult(resultEl, '<p>Error: ' + esc(data && (data.error || data.detail) ? (data.error || data.detail) : 'unknown') + '</p>', true);
@@ -2409,8 +2629,10 @@ export function renderReceiptHtml(model) {
2409
2629
  .then(function(data){
2410
2630
  if (data && data.ok) {
2411
2631
  var errs = data.errors && data.errors.length ? ' · ' + data.errors.length + ' error(s)' : '';
2412
- setResult(resultEl, '<p>Imported ' + data.imported + ' event(s)' + errs + ' - reloading...</p>', false);
2413
- setTimeout(function(){ location.reload(); }, 800);
2632
+ var importMsg = 'Imported ' + data.imported + ' event(s)' + errs;
2633
+ setResult(resultEl, '<p>' + esc(importMsg) + ' - reloading...</p>', false);
2634
+ persistResult('import', importMsg);
2635
+ setTimeout(function(){ location.reload(); }, 1500);
2414
2636
  } else {
2415
2637
  setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
2416
2638
  if (btn) btn.disabled = false;
@@ -2450,12 +2672,8 @@ export function renderReceiptHtml(model) {
2450
2672
  })();
2451
2673
  </script>`
2452
2674
  : "";
2453
- // Zones never render as bare whitespace: with no panels they get a hint.
2454
- const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
2455
- ? parts.filter(Boolean).join("\n ")
2456
- : `<p class="mute small zone-empty">${empty}</p>`;
2457
- const overviewBody = zoneBody([dashboardPanel(dashboard), sessionsPanel(agg, nowMs, changes), reasonsPanel(agg)], "No activity in this window yet - analytics appear once events land.");
2458
- const certifyBody = zoneBody([model.certify ? certifyDetailPanel(model.certify.requirements) : ""], "No live evaluation yet - wrap tool calls and run kya certify to build the baseline.");
2675
+ // (zoneBody lives at module level; buildLiveFragments above produces the
2676
+ // overview and certify bodies through the same helper.)
2459
2677
  const systemBody = zoneBody([
2460
2678
  capabilitiesPanel(model),
2461
2679
  gatePanel(model.gate, nowMs),
@@ -2515,18 +2733,12 @@ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
2515
2733
  ${blockedBanner}
2516
2734
  <section class="zone" id="overview" aria-labelledby="tab-overview">
2517
2735
  <div class="hero">
2518
- ${heroCertifyPanel(model.certify)}
2519
- <div class="hero-tiles">
2520
- ${verdictsHeroCard(c, events.length)}
2521
- ${activityHeroCard(agg, dashboard, events.length)}
2522
- ${gatewayHeroCard(model.gate, nowMs)}
2523
- ${importHeroCard(model.imports, nowMs, livePaged)}
2524
- ${discoveryHeroCard(model.discovery, nowMs, livePaged)}
2525
- ${investigateHeroCard(model.investigate, nowMs, livePaged)}
2526
- ${showbackHeroCard(model.showback, model.events)}
2736
+ ${fr.fragments.heroCertify}
2737
+ <div class="hero-tiles" id="frag-hero-tiles">
2738
+ ${fr.fragments.heroTiles}
2527
2739
  </div>
2528
2740
  </div>
2529
- ${overviewBody}
2741
+ <div id="frag-overview-panels">${fr.fragments.overviewPanels}</div>
2530
2742
  </section>
2531
2743
  <section class="zone" id="activity" aria-labelledby="tab-activity">
2532
2744
  <div class="feed-search">
@@ -2535,17 +2747,10 @@ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
2535
2747
  </div>
2536
2748
  ${!model.liveToken && model.searchQuery ? '<p class="feed-search-note">Semantic ranking requires the live report server (<code>kya start</code>).</p>' : ""}
2537
2749
  <div class="feed-head">
2538
- <div class="stats" aria-label="Counts">
2539
- <button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
2540
- <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
2541
- <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>
2542
- <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
2543
- ${chips.modes}${chips.planes}
2544
- <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>
2545
- </div>
2546
- ${chips.products}
2547
- ${chips.projects}
2548
- ${chips.servers}
2750
+ <div class="stats" aria-label="Counts" id="stat-counts">${fr.fragments.statCounts}</div>
2751
+ <div id="chip-products">${chips.products}</div>
2752
+ <div id="chip-projects">${chips.projects}</div>
2753
+ <div id="chip-servers">${chips.servers}</div>
2549
2754
  </div>
2550
2755
  <section class="feed" id="feed" aria-label="Activity feed">
2551
2756
  ${initialFeed.feedHtml}
@@ -2556,14 +2761,14 @@ ${initialFeed.paginationHtml}
2556
2761
  </section>
2557
2762
  <section class="zone" id="changes" aria-labelledby="tab-changes">
2558
2763
  <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
2559
- ${changesPanel(changes, nowMs)}
2764
+ ${fr.fragments.changesList}
2560
2765
  </section>
2561
2766
  </section>
2562
2767
  <section class="zone" id="certify" aria-labelledby="tab-certify">
2563
- ${certifyBody}
2768
+ <div id="frag-certify">${fr.fragments.certifyBody}</div>
2564
2769
  </section>
2565
2770
  <section class="zone" id="orr" aria-labelledby="tab-orr">
2566
- ${orrPanel(model.orr, nowMs, livePaged)}
2771
+ <div id="frag-orr">${fr.fragments.orrPanel}</div>
2567
2772
  </section>
2568
2773
  ${gatePagePanel(model.gate, nowMs)}
2569
2774
  <section class="zone" id="system" aria-labelledby="tab-system">
@@ -2574,7 +2779,6 @@ ${filterScript}
2574
2779
  ${staticSearchScript}
2575
2780
  ${themeScript}
2576
2781
  ${gateScript}
2577
- ${liveScript}
2578
2782
  </body>
2579
2783
  </html>`;
2580
2784
  // Defense-in-depth: block known secret shapes before serving HTML.