@shield-agent/kya 0.32.0 → 0.32.4

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 (40) hide show
  1. package/.cursor-plugin/plugin.json +1 -1
  2. package/.mcp.json +1 -1
  3. package/README.md +3 -3
  4. package/amp/settings.example.json +1 -1
  5. package/claude/claude_desktop_config.example.json +1 -1
  6. package/cline/cline_mcp_settings.example.json +1 -1
  7. package/copilot/mcp-config.example.json +1 -1
  8. package/dist/commands/orr.d.ts.map +1 -1
  9. package/dist/commands/orr.js +57 -3
  10. package/dist/commands/orr.js.map +1 -1
  11. package/dist/receipt/enrich.d.ts +8 -0
  12. package/dist/receipt/enrich.d.ts.map +1 -1
  13. package/dist/receipt/enrich.js +33 -0
  14. package/dist/receipt/enrich.js.map +1 -1
  15. package/dist/receipt/live-server.d.ts +7 -0
  16. package/dist/receipt/live-server.d.ts.map +1 -1
  17. package/dist/receipt/live-server.js +75 -18
  18. package/dist/receipt/live-server.js.map +1 -1
  19. package/dist/receipt/receipt-css.d.ts.map +1 -1
  20. package/dist/receipt/receipt-css.js +9 -0
  21. package/dist/receipt/receipt-css.js.map +1 -1
  22. package/dist/receipt/render-receipt.d.ts +10 -0
  23. package/dist/receipt/render-receipt.d.ts.map +1 -1
  24. package/dist/receipt/render-receipt.js +294 -69
  25. package/dist/receipt/render-receipt.js.map +1 -1
  26. package/droid/README.md +1 -1
  27. package/droid/mcp.example.json +1 -1
  28. package/gemini/settings.example.json +1 -1
  29. package/grok/README.md +1 -1
  30. package/kilo/kilo.example.json +1 -1
  31. package/kimi/mcp.example.json +1 -1
  32. package/kiro/mcp.example.json +1 -1
  33. package/manifest.json +1 -1
  34. package/mastracode/mcp.example.json +1 -1
  35. package/mcp.json +1 -1
  36. package/openai/codex.config.example.toml +1 -1
  37. package/opencode/opencode.example.json +1 -1
  38. package/package.json +1 -1
  39. package/qwen/settings.example.json +1 -1
  40. package/server.json +2 -2
@@ -1289,6 +1289,19 @@ function orrPanel(orr, nowMs, live) {
1289
1289
  ? `<p class="line">Most urgent fix: ${esc(clip(orr.mostUrgentFix, 200))}</p>`
1290
1290
  : "";
1291
1291
  const when = orr.generatedAt ? ` · ${esc(relativeTime(orr.generatedAt, nowMs))}` : "";
1292
+ const findings = orr.findings.length
1293
+ ? `<ul class="orrfindings">${orr.findings
1294
+ .map((f) => {
1295
+ const evidence = f.evidence
1296
+ ? ` <span class="mute">- ${esc(clip(f.evidence, 160))}</span>`
1297
+ : "";
1298
+ const detail = f.detail
1299
+ ? `<br><span class="mute small">${esc(clip(f.detail, 200))}</span>`
1300
+ : "";
1301
+ return `<li><span class="pill ${esc(severityCls(f.severity))}">${esc(f.severity)}</span> ${esc(clip(f.title, 120))}${evidence}${detail}</li>`;
1302
+ })
1303
+ .join("")}</ul>`
1304
+ : "";
1292
1305
  return `<section class="panel" aria-label="ORR">
1293
1306
  <h2>Operational readiness</h2>
1294
1307
  <div class="orrline">
@@ -1298,10 +1311,18 @@ function orrPanel(orr, nowMs, live) {
1298
1311
  </div>
1299
1312
  ${failure}
1300
1313
  ${fix}
1314
+ ${findings}
1301
1315
  <p class="mute small">scorecards: ${orr.scorecards.pass} pass · ${orr.scorecards.fail} fail · ${orr.scorecards.partial} partial · ${orr.scorecards.notEvaluated} n/e${when} · auto-refreshes every 30 min while the report runs</p>
1302
1316
  ${run}
1303
1317
  </section>`;
1304
1318
  }
1319
+ function severityCls(severity) {
1320
+ if (severity === "critical" || severity === "high")
1321
+ return "orr-red";
1322
+ if (severity === "medium")
1323
+ return "orr-amber";
1324
+ return "";
1325
+ }
1305
1326
  function usdText(usd) {
1306
1327
  return usd === null ? "USD n/a" : `~$${usd.toFixed(2)}`;
1307
1328
  }
@@ -1320,7 +1341,7 @@ function kpiTile(label, value, cls = "") {
1320
1341
  */
1321
1342
  function heroCertifyPanel(certify) {
1322
1343
  if (!certify) {
1323
- return `<section class="panel hero-certify none" aria-label="Certify">
1344
+ return `<section class="panel hero-certify none" id="frag-hero-certify" aria-label="Certify">
1324
1345
  <h2>Certify - Agent Trust Baseline</h2>
1325
1346
  <div class="orrline">
1326
1347
  <span class="pill">not evaluated</span>
@@ -1373,7 +1394,7 @@ ${certify.topGaps
1373
1394
  <span class="mute small">or run <code>kya certify --open</code> for the signed bundle</span>
1374
1395
  </div>`;
1375
1396
  // Pill tones reuse the ORR palette: pass is green, gap is amber.
1376
- return `<section class="panel hero-certify ${state}" aria-label="Certify">
1397
+ return `<section class="panel hero-certify ${state}" id="frag-hero-certify" aria-label="Certify">
1377
1398
  <h2>Certify - Agent Trust Baseline</h2>
1378
1399
  <div class="orrline">
1379
1400
  <span class="pill orr-${state === "pass" ? "green" : "amber"}">${esc(certify.result)}</span>
@@ -1852,6 +1873,63 @@ function paginationBar(total, page, pages) {
1852
1873
  }
1853
1874
  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
1875
  }
1876
+ /** Zones never render as bare whitespace: with no panels they get a hint. */
1877
+ const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
1878
+ ? parts.filter(Boolean).join("\n ")
1879
+ : `<p class="mute small zone-empty">${empty}</p>`;
1880
+ /** Inner markup of the Activity counts bar (verdict buttons + mode/plane chips). */
1881
+ function statCountsInner(c, chips) {
1882
+ 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>
1883
+ <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
1884
+ <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>
1885
+ <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
1886
+ ${chips.modes}${chips.planes}
1887
+ <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>`;
1888
+ }
1889
+ /**
1890
+ * Live-refresh fragments: the containers POST /fragments re-renders so the
1891
+ * report updates in place instead of reloading the whole page.
1892
+ * renderReceiptHtml builds the initial page through the same helpers, so
1893
+ * first paint and refreshes cannot drift apart.
1894
+ */
1895
+ export function buildLiveFragments(model, livePaged) {
1896
+ const nowMs = Date.now();
1897
+ const events = normalizeTrailEvents(model.events);
1898
+ const c = countVerdicts(events);
1899
+ const agg = aggregateEvents(events);
1900
+ const chips = statChips(agg);
1901
+ const dashboard = computeDashboard(events);
1902
+ const changes = buildChangesModel(events);
1903
+ return {
1904
+ fragments: {
1905
+ statCounts: statCountsInner(c, chips),
1906
+ chipProducts: chips.products,
1907
+ chipProjects: chips.projects,
1908
+ chipServers: chips.servers,
1909
+ heroCertify: heroCertifyPanel(model.certify),
1910
+ heroTiles: [
1911
+ verdictsHeroCard(c, events.length),
1912
+ activityHeroCard(agg, dashboard, events.length),
1913
+ gatewayHeroCard(model.gate, nowMs),
1914
+ importHeroCard(model.imports, nowMs, livePaged),
1915
+ discoveryHeroCard(model.discovery, nowMs, livePaged),
1916
+ investigateHeroCard(model.investigate, nowMs, livePaged),
1917
+ showbackHeroCard(model.showback, events),
1918
+ ].join("\n "),
1919
+ overviewPanels: zoneBody([dashboardPanel(dashboard), sessionsPanel(agg, nowMs, changes), reasonsPanel(agg)], "No activity in this window yet - analytics appear once events land."),
1920
+ certifyBody: zoneBody([model.certify ? certifyDetailPanel(model.certify.requirements) : ""], "No live evaluation yet - wrap tool calls and run kya certify to build the baseline."),
1921
+ orrPanel: orrPanel(model.orr, nowMs, livePaged),
1922
+ changesList: changesPanel(changes, nowMs),
1923
+ },
1924
+ nav: {
1925
+ overview: events.length,
1926
+ activity: events.length,
1927
+ changes: changes.fileCount,
1928
+ certify: model.certify?.gap ?? null,
1929
+ orr: model.orr ? model.orr.scorecards.fail : null,
1930
+ },
1931
+ };
1932
+ }
1855
1933
  export function renderReceiptHtml(model) {
1856
1934
  const nowMs = Date.now();
1857
1935
  const events = normalizeTrailEvents(model.events);
@@ -1871,11 +1949,12 @@ export function renderReceiptHtml(model) {
1871
1949
  page: 1,
1872
1950
  pages: 1,
1873
1951
  };
1874
- const c = countVerdicts(events);
1875
1952
  const agg = aggregateEvents(events);
1876
1953
  const chips = statChips(agg);
1877
- const dashboard = computeDashboard(events);
1878
1954
  const changes = buildChangesModel(events);
1955
+ // Live-refresh fragments render the initial page AND the POST /fragments
1956
+ // refresh, so the two can never drift apart.
1957
+ const fr = buildLiveFragments(model, livePaged);
1879
1958
  const blocked = events.filter((e) => e.neverEvent || e.reasonCode === "NEVER_EVENT");
1880
1959
  const tools = model.mcpSeen && model.mcpSeen.length > 0
1881
1960
  ? model.mcpSeen.map((m) => m.toolId)
@@ -1894,19 +1973,8 @@ export function renderReceiptHtml(model) {
1894
1973
  </footer>`;
1895
1974
  // liveToken is minted base64url by the live server - safe in a JS string
1896
1975
  // 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
- : "";
1976
+ // Live updates ride the filter script's EventSource + /fragments refresh;
1977
+ // there is deliberately no page-level reload-on-change script.
1910
1978
  // Client-side chip filters, pagination, and search: only emitted for live
1911
1979
  // reports (liveToken present). Static receipts render a separate BM25-only
1912
1980
  // search script below. State maps are null-prototype: attacker-controlled
@@ -1932,8 +2000,8 @@ export function renderReceiptHtml(model) {
1932
2000
  var page = 1;
1933
2001
  var q = '';
1934
2002
  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]');
2003
+ var stopped = false;
2004
+ var pill = null;
1937
2005
  function readToken(){
1938
2006
  var s = location.search;
1939
2007
  if (s.indexOf('?') !== 0) return '';
@@ -1945,6 +2013,24 @@ export function renderReceiptHtml(model) {
1945
2013
  }
1946
2014
  return '';
1947
2015
  }
2016
+ // 401 on any live request means the report server restarted and this page's
2017
+ // token is stale. Stop refreshing and tell the user how to get a fresh link
2018
+ // instead of looping on a dead token.
2019
+ function showRestartBanner(){
2020
+ if (stopped) return;
2021
+ stopped = true;
2022
+ if (pill) { pill.textContent = 'Offline'; pill.classList.add('off'); }
2023
+ try {
2024
+ var main = document.querySelector('main');
2025
+ if (!main || document.getElementById('kya-restart-banner') || !document.createElement) return;
2026
+ var div = document.createElement('div');
2027
+ div.id = 'kya-restart-banner';
2028
+ div.className = 'alert';
2029
+ div.setAttribute('role', 'status');
2030
+ div.textContent = 'Report server restarted. Re-run kya receipt --open (or kya start) for a fresh link.';
2031
+ main.insertBefore(div, main.firstChild);
2032
+ } catch (e) { /* banner is best-effort */ }
2033
+ }
1948
2034
  function parse(){
1949
2035
  state = Object.create(null);
1950
2036
  page = 1;
@@ -2047,11 +2133,12 @@ export function renderReceiptHtml(model) {
2047
2133
  group.classList.toggle('filtered-out', on && !vis);
2048
2134
  });
2049
2135
  }
2050
- chips.forEach(function(chip){
2136
+ document.querySelectorAll('[data-fgroup]').forEach(function(chip){
2051
2137
  var pressed = !!(state[chip.dataset.fgroup] && state[chip.dataset.fgroup][chip.dataset.fvalue]);
2052
2138
  chip.setAttribute('aria-pressed', pressed ? 'true' : 'false');
2053
2139
  });
2054
- if (clearBtn) clearBtn.hidden = !active();
2140
+ var cb = document.getElementById('clear-filters');
2141
+ if (cb) cb.hidden = !active();
2055
2142
  }
2056
2143
  function updateMeta(total){
2057
2144
  if (!searchMeta) return;
@@ -2074,7 +2161,10 @@ export function renderReceiptHtml(model) {
2074
2161
  headers: { 'Content-Type': 'application/json' },
2075
2162
  body: JSON.stringify({ filters: serverFilters, page: page, q: q })
2076
2163
  })
2077
- .then(function(r){ return r.json(); })
2164
+ .then(function(r){
2165
+ if (r.status === 401) { showRestartBanner(); throw new Error('unauthorized'); }
2166
+ return r.json();
2167
+ })
2078
2168
  .then(function(data){
2079
2169
  if (feed && data.feedHtml) feed.innerHTML = data.feedHtml;
2080
2170
  if (bar && data.paginationHtml) bar.innerHTML = data.paginationHtml;
@@ -2082,19 +2172,31 @@ export function renderReceiptHtml(model) {
2082
2172
  })
2083
2173
  .catch(function(err){ console.error('KYA feed update failed', err); });
2084
2174
  }
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;
2175
+ // Filter chips are delegated on document: live refresh swaps chip containers
2176
+ // innerHTML, so per-element bindings would die on the first update. The
2177
+ // pressed state is reapplied by apply() from the query-param state.
2178
+ document.addEventListener('click', function(e){
2179
+ var t = e.target;
2180
+ if (!t || !t.closest) return;
2181
+ if (t.closest('#clear-filters')) {
2182
+ state = Object.create(null); page = 1; q = '';
2183
+ if (searchInput) searchInput.value = '';
2095
2184
  save(); apply();
2096
2185
  if (LIVE) feedUpdate();
2097
- });
2186
+ return;
2187
+ }
2188
+ var chip = t.closest('[data-fgroup]');
2189
+ if (!chip) return;
2190
+ var g = chip.dataset.fgroup, v = chip.dataset.fvalue;
2191
+ if (state[g] && state[g][v]) {
2192
+ delete state[g][v];
2193
+ if (!Object.keys(state[g]).length) delete state[g];
2194
+ } else {
2195
+ (state[g] || (state[g] = Object.create(null)))[v] = true;
2196
+ }
2197
+ page = 1;
2198
+ save(); apply();
2199
+ if (LIVE) feedUpdate();
2098
2200
  });
2099
2201
  if (LIVE && bar) {
2100
2202
  bar.addEventListener('click', function(e){
@@ -2121,10 +2223,123 @@ export function renderReceiptHtml(model) {
2121
2223
  if (LIVE) debounce = setTimeout(feedUpdate, 120);
2122
2224
  });
2123
2225
  }
2124
- if (clearBtn) clearBtn.addEventListener('click', function(){ state = Object.create(null); page = 1; q = ''; if (searchInput) searchInput.value = ''; save(); apply(); if (LIVE) feedUpdate(); });
2125
2226
  parse(); apply();
2126
2227
  if (searchInput) searchInput.value = q;
2127
2228
  if (LIVE && (active() || page !== 1)) feedUpdate();
2229
+
2230
+ // Live refresh: SSE says what changed, then only the affected containers
2231
+ // re-render in place. No full-page reloads, so scroll, focus, and filter
2232
+ // state survive and new rows stream into the feed on page 1.
2233
+ if (LIVE) {
2234
+ pill = document.querySelector('.live');
2235
+ var dirty = false;
2236
+ var lastFrag = 0;
2237
+ var fragTimer = 0;
2238
+ var sseDebounce = 0;
2239
+ function flash(el){
2240
+ el.classList.remove('frag-flash');
2241
+ void el.offsetWidth;
2242
+ el.classList.add('frag-flash');
2243
+ }
2244
+ function updateNavCounts(nav){
2245
+ Object.keys(nav).forEach(function(k){
2246
+ var link = document.getElementById('tab-' + k);
2247
+ if (!link) return;
2248
+ var span = link.querySelector('.nav-count');
2249
+ if (nav[k] === null || nav[k] === undefined) {
2250
+ if (span) span.parentNode.removeChild(span);
2251
+ return;
2252
+ }
2253
+ if (!span) {
2254
+ span = document.createElement('span');
2255
+ span.className = 'nav-count';
2256
+ link.appendChild(span);
2257
+ }
2258
+ span.textContent = String(nav[k]);
2259
+ });
2260
+ }
2261
+ var FRAG_TARGETS = {
2262
+ statCounts: 'stat-counts',
2263
+ chipProducts: 'chip-products',
2264
+ chipProjects: 'chip-projects',
2265
+ chipServers: 'chip-servers',
2266
+ heroCertify: 'frag-hero-certify',
2267
+ heroTiles: 'frag-hero-tiles',
2268
+ overviewPanels: 'frag-overview-panels',
2269
+ certifyBody: 'frag-certify',
2270
+ orrPanel: 'frag-orr',
2271
+ changesList: 'changes-list'
2272
+ };
2273
+ function loadFragments(){
2274
+ lastFrag = Date.now();
2275
+ fetch('/fragments?t=' + encodeURIComponent(readToken()), {
2276
+ method: 'POST',
2277
+ headers: { 'Content-Type': 'application/json' },
2278
+ body: '{}'
2279
+ })
2280
+ .then(function(r){
2281
+ if (r.status === 401) { showRestartBanner(); throw new Error('unauthorized'); }
2282
+ if (!r.ok) throw new Error('HTTP ' + r.status);
2283
+ return r.json();
2284
+ })
2285
+ .then(function(data){
2286
+ if (!data || !data.ok || !data.fragments) return;
2287
+ Object.keys(FRAG_TARGETS).forEach(function(k){
2288
+ var el = document.getElementById(FRAG_TARGETS[k]);
2289
+ if (!el || typeof data.fragments[k] !== 'string') return;
2290
+ if (el.innerHTML !== data.fragments[k]) {
2291
+ el.innerHTML = data.fragments[k];
2292
+ flash(el);
2293
+ }
2294
+ });
2295
+ // Fresh chip HTML arrives unpressed: reapply from the query-param state.
2296
+ apply();
2297
+ if (data.nav) updateNavCounts(data.nav);
2298
+ })
2299
+ .catch(function(err){ if (!stopped) console.error('KYA fragments update failed', err); });
2300
+ }
2301
+ function scheduleFragments(){
2302
+ if (fragTimer || stopped) return;
2303
+ var wait = Math.max(0, 2000 - (Date.now() - lastFrag));
2304
+ fragTimer = setTimeout(function(){
2305
+ fragTimer = 0;
2306
+ if (!dirty || document.hidden) return;
2307
+ dirty = false;
2308
+ loadFragments();
2309
+ }, wait);
2310
+ }
2311
+ function refresh(){
2312
+ if (stopped) return;
2313
+ if (document.hidden) { dirty = true; return; }
2314
+ dirty = false;
2315
+ feedUpdate();
2316
+ scheduleFragments();
2317
+ }
2318
+ // EventSource is absent in DOM shims and very old engines; the page still
2319
+ // works, it just does not stream updates.
2320
+ if (typeof EventSource === 'function') {
2321
+ var es = new EventSource('/events?t=' + encodeURIComponent(readToken()));
2322
+ es.onmessage = function(){
2323
+ if (stopped) return;
2324
+ dirty = true;
2325
+ clearTimeout(sseDebounce);
2326
+ sseDebounce = setTimeout(refresh, 300);
2327
+ };
2328
+ es.onerror = function(){
2329
+ // EventSource retries on its own; the pill reflects the outage and a
2330
+ // 401 on the next real request surfaces the restart banner.
2331
+ if (pill) { pill.textContent = 'Offline'; pill.classList.add('off'); }
2332
+ };
2333
+ }
2334
+ if (document.addEventListener) {
2335
+ document.addEventListener('visibilitychange', function(){
2336
+ if (!document.hidden && dirty && !stopped) {
2337
+ clearTimeout(sseDebounce);
2338
+ sseDebounce = setTimeout(refresh, 100);
2339
+ }
2340
+ });
2341
+ }
2342
+ }
2128
2343
  })();
2129
2344
  </script>`
2130
2345
  : "";
@@ -2300,7 +2515,15 @@ export function renderReceiptHtml(model) {
2300
2515
  body: JSON.stringify(body)
2301
2516
  });
2302
2517
  }
2303
- function parseJson(res){ return res.json(); }
2518
+ function parseJson(res){
2519
+ if (!res.ok) {
2520
+ var msg = res.status === 401
2521
+ ? 'Session expired or unauthorized (401). Reload the report from kya start, then try again.'
2522
+ : 'Request failed with HTTP ' + res.status;
2523
+ throw new Error(msg);
2524
+ }
2525
+ return res.json();
2526
+ }
2304
2527
 
2305
2528
  var form = document.getElementById('gate-play-form');
2306
2529
  if (form) {
@@ -2359,6 +2582,23 @@ export function renderReceiptHtml(model) {
2359
2582
  });
2360
2583
  });
2361
2584
 
2585
+ // Success feedback must survive the confirmation reload: the result div is
2586
+ // server-rendered empty on the fresh page, so without this the user sees a
2587
+ // blip for 800 ms and then an apparently unchanged page.
2588
+ function persistResult(action, msg){
2589
+ try { sessionStorage.setItem('kya:action-result', JSON.stringify({ a: action, m: msg })); } catch (e) {}
2590
+ }
2591
+ (function showPersistedResult(){
2592
+ var raw;
2593
+ try { raw = sessionStorage.getItem('kya:action-result'); sessionStorage.removeItem('kya:action-result'); } catch (e) { return; }
2594
+ if (!raw) return;
2595
+ var data;
2596
+ try { data = JSON.parse(raw); } catch (e) { return; }
2597
+ var el = document.getElementById('report-action-result-' + data.a)
2598
+ || (data.a === 'import' ? document.getElementById('import-result') : null);
2599
+ if (el && typeof data.m === 'string') setResult(el, '<p>' + esc(data.m) + '</p>', false);
2600
+ })();
2601
+
2362
2602
  document.querySelectorAll('[data-report-action]').forEach(function(btn){
2363
2603
  btn.addEventListener('click', function(){
2364
2604
  var action = btn.getAttribute('data-report-action');
@@ -2380,7 +2620,8 @@ export function renderReceiptHtml(model) {
2380
2620
  if (action === 'otel-test') {
2381
2621
  btn.disabled = false;
2382
2622
  } else {
2383
- setTimeout(function(){ location.reload(); }, 800);
2623
+ persistResult(action, msg);
2624
+ setTimeout(function(){ location.reload(); }, 1500);
2384
2625
  }
2385
2626
  } else {
2386
2627
  setResult(resultEl, '<p>Error: ' + esc(data && (data.error || data.detail) ? (data.error || data.detail) : 'unknown') + '</p>', true);
@@ -2409,8 +2650,10 @@ export function renderReceiptHtml(model) {
2409
2650
  .then(function(data){
2410
2651
  if (data && data.ok) {
2411
2652
  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);
2653
+ var importMsg = 'Imported ' + data.imported + ' event(s)' + errs;
2654
+ setResult(resultEl, '<p>' + esc(importMsg) + ' - reloading...</p>', false);
2655
+ persistResult('import', importMsg);
2656
+ setTimeout(function(){ location.reload(); }, 1500);
2414
2657
  } else {
2415
2658
  setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
2416
2659
  if (btn) btn.disabled = false;
@@ -2450,12 +2693,8 @@ export function renderReceiptHtml(model) {
2450
2693
  })();
2451
2694
  </script>`
2452
2695
  : "";
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.");
2696
+ // (zoneBody lives at module level; buildLiveFragments above produces the
2697
+ // overview and certify bodies through the same helper.)
2459
2698
  const systemBody = zoneBody([
2460
2699
  capabilitiesPanel(model),
2461
2700
  gatePanel(model.gate, nowMs),
@@ -2515,18 +2754,12 @@ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
2515
2754
  ${blockedBanner}
2516
2755
  <section class="zone" id="overview" aria-labelledby="tab-overview">
2517
2756
  <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)}
2757
+ ${fr.fragments.heroCertify}
2758
+ <div class="hero-tiles" id="frag-hero-tiles">
2759
+ ${fr.fragments.heroTiles}
2527
2760
  </div>
2528
2761
  </div>
2529
- ${overviewBody}
2762
+ <div id="frag-overview-panels">${fr.fragments.overviewPanels}</div>
2530
2763
  </section>
2531
2764
  <section class="zone" id="activity" aria-labelledby="tab-activity">
2532
2765
  <div class="feed-search">
@@ -2535,17 +2768,10 @@ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
2535
2768
  </div>
2536
2769
  ${!model.liveToken && model.searchQuery ? '<p class="feed-search-note">Semantic ranking requires the live report server (<code>kya start</code>).</p>' : ""}
2537
2770
  <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}
2771
+ <div class="stats" aria-label="Counts" id="stat-counts">${fr.fragments.statCounts}</div>
2772
+ <div id="chip-products">${chips.products}</div>
2773
+ <div id="chip-projects">${chips.projects}</div>
2774
+ <div id="chip-servers">${chips.servers}</div>
2549
2775
  </div>
2550
2776
  <section class="feed" id="feed" aria-label="Activity feed">
2551
2777
  ${initialFeed.feedHtml}
@@ -2556,14 +2782,14 @@ ${initialFeed.paginationHtml}
2556
2782
  </section>
2557
2783
  <section class="zone" id="changes" aria-labelledby="tab-changes">
2558
2784
  <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
2559
- ${changesPanel(changes, nowMs)}
2785
+ ${fr.fragments.changesList}
2560
2786
  </section>
2561
2787
  </section>
2562
2788
  <section class="zone" id="certify" aria-labelledby="tab-certify">
2563
- ${certifyBody}
2789
+ <div id="frag-certify">${fr.fragments.certifyBody}</div>
2564
2790
  </section>
2565
2791
  <section class="zone" id="orr" aria-labelledby="tab-orr">
2566
- ${orrPanel(model.orr, nowMs, livePaged)}
2792
+ <div id="frag-orr">${fr.fragments.orrPanel}</div>
2567
2793
  </section>
2568
2794
  ${gatePagePanel(model.gate, nowMs)}
2569
2795
  <section class="zone" id="system" aria-labelledby="tab-system">
@@ -2574,7 +2800,6 @@ ${filterScript}
2574
2800
  ${staticSearchScript}
2575
2801
  ${themeScript}
2576
2802
  ${gateScript}
2577
- ${liveScript}
2578
2803
  </body>
2579
2804
  </html>`;
2580
2805
  // Defense-in-depth: block known secret shapes before serving HTML.