@shield-agent/kya 0.33.6 → 0.35.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.
Files changed (257) hide show
  1. package/.cursor-plugin/plugin.json +2 -2
  2. package/.mcp.json +1 -1
  3. package/LIMITATIONS.md +2 -1
  4. package/README.md +39 -640
  5. package/amp/settings.example.json +1 -1
  6. package/assets/report-blocked.png +0 -0
  7. package/assets/report-certify.png +0 -0
  8. package/assets/report-overview.png +0 -0
  9. package/assets/screenshot-features.json +18 -0
  10. package/claude/claude_desktop_config.example.json +1 -1
  11. package/cline/cline_mcp_settings.example.json +1 -1
  12. package/copilot/mcp-config.example.json +1 -1
  13. package/dist/alerts/config.d.ts +22 -0
  14. package/dist/alerts/config.d.ts.map +1 -0
  15. package/dist/alerts/config.js +59 -0
  16. package/dist/alerts/config.js.map +1 -0
  17. package/dist/alerts/constants.d.ts +5 -0
  18. package/dist/alerts/constants.d.ts.map +1 -0
  19. package/dist/alerts/constants.js +5 -0
  20. package/dist/alerts/constants.js.map +1 -0
  21. package/dist/alerts/desktop.d.ts +19 -0
  22. package/dist/alerts/desktop.d.ts.map +1 -0
  23. package/dist/alerts/desktop.js +57 -0
  24. package/dist/alerts/desktop.js.map +1 -0
  25. package/dist/alerts/incident-alert.d.ts +42 -0
  26. package/dist/alerts/incident-alert.d.ts.map +1 -0
  27. package/dist/alerts/incident-alert.js +84 -0
  28. package/dist/alerts/incident-alert.js.map +1 -0
  29. package/dist/alerts/scan.d.ts +37 -0
  30. package/dist/alerts/scan.d.ts.map +1 -0
  31. package/dist/alerts/scan.js +188 -0
  32. package/dist/alerts/scan.js.map +1 -0
  33. package/dist/alerts/state.d.ts +55 -0
  34. package/dist/alerts/state.d.ts.map +1 -0
  35. package/dist/alerts/state.js +178 -0
  36. package/dist/alerts/state.js.map +1 -0
  37. package/dist/alerts/triage-input.d.ts +36 -0
  38. package/dist/alerts/triage-input.d.ts.map +1 -0
  39. package/dist/alerts/triage-input.js +159 -0
  40. package/dist/alerts/triage-input.js.map +1 -0
  41. package/dist/alerts/triage-model.d.ts +42 -0
  42. package/dist/alerts/triage-model.d.ts.map +1 -0
  43. package/dist/alerts/triage-model.js +126 -0
  44. package/dist/alerts/triage-model.js.map +1 -0
  45. package/dist/alerts/triage-prompt.d.ts +23 -0
  46. package/dist/alerts/triage-prompt.d.ts.map +1 -0
  47. package/dist/alerts/triage-prompt.js +53 -0
  48. package/dist/alerts/triage-prompt.js.map +1 -0
  49. package/dist/alerts/triage-schema.json +52 -0
  50. package/dist/alerts/triage-setup.d.ts +77 -0
  51. package/dist/alerts/triage-setup.d.ts.map +1 -0
  52. package/dist/alerts/triage-setup.js +290 -0
  53. package/dist/alerts/triage-setup.js.map +1 -0
  54. package/dist/alerts/triage-status.d.ts +26 -0
  55. package/dist/alerts/triage-status.d.ts.map +1 -0
  56. package/dist/alerts/triage-status.js +61 -0
  57. package/dist/alerts/triage-status.js.map +1 -0
  58. package/dist/alerts/triage-tokenizer.d.ts +29 -0
  59. package/dist/alerts/triage-tokenizer.d.ts.map +1 -0
  60. package/dist/alerts/triage-tokenizer.js +82 -0
  61. package/dist/alerts/triage-tokenizer.js.map +1 -0
  62. package/dist/card-png.d.ts +3 -0
  63. package/dist/card-png.d.ts.map +1 -0
  64. package/dist/card-png.js +54 -0
  65. package/dist/card-png.js.map +1 -0
  66. package/dist/card.d.ts +62 -0
  67. package/dist/card.d.ts.map +1 -0
  68. package/dist/card.js +154 -0
  69. package/dist/card.js.map +1 -0
  70. package/dist/certify/evaluate.d.ts +11 -1
  71. package/dist/certify/evaluate.d.ts.map +1 -1
  72. package/dist/certify/evaluate.js +8 -1
  73. package/dist/certify/evaluate.js.map +1 -1
  74. package/dist/certify/render.d.ts.map +1 -1
  75. package/dist/certify/render.js +10 -1
  76. package/dist/certify/render.js.map +1 -1
  77. package/dist/cli.d.ts +2 -0
  78. package/dist/cli.d.ts.map +1 -1
  79. package/dist/cli.js +234 -49
  80. package/dist/cli.js.map +1 -1
  81. package/dist/commands/alerts.d.ts +16 -0
  82. package/dist/commands/alerts.d.ts.map +1 -0
  83. package/dist/commands/alerts.js +112 -0
  84. package/dist/commands/alerts.js.map +1 -0
  85. package/dist/commands/connect.d.ts.map +1 -1
  86. package/dist/commands/connect.js +4 -3
  87. package/dist/commands/connect.js.map +1 -1
  88. package/dist/commands/doctor.d.ts.map +1 -1
  89. package/dist/commands/doctor.js +12 -1
  90. package/dist/commands/doctor.js.map +1 -1
  91. package/dist/commands/hook.d.ts.map +1 -1
  92. package/dist/commands/hook.js +94 -8
  93. package/dist/commands/hook.js.map +1 -1
  94. package/dist/commands/init.d.ts +2 -0
  95. package/dist/commands/init.d.ts.map +1 -1
  96. package/dist/commands/init.js +2 -0
  97. package/dist/commands/init.js.map +1 -1
  98. package/dist/commands/notify-flush.d.ts.map +1 -1
  99. package/dist/commands/notify-flush.js +3 -0
  100. package/dist/commands/notify-flush.js.map +1 -1
  101. package/dist/commands/receipt-serve.d.ts +1 -1
  102. package/dist/commands/receipt-serve.d.ts.map +1 -1
  103. package/dist/commands/receipt-serve.js +3 -2
  104. package/dist/commands/receipt-serve.js.map +1 -1
  105. package/dist/commands/receipt.d.ts +19 -0
  106. package/dist/commands/receipt.d.ts.map +1 -1
  107. package/dist/commands/receipt.js +21 -1
  108. package/dist/commands/receipt.js.map +1 -1
  109. package/dist/commands/serve-mcp.d.ts +2 -0
  110. package/dist/commands/serve-mcp.d.ts.map +1 -1
  111. package/dist/commands/serve-mcp.js +2 -0
  112. package/dist/commands/serve-mcp.js.map +1 -1
  113. package/dist/commands/start.d.ts +50 -2
  114. package/dist/commands/start.d.ts.map +1 -1
  115. package/dist/commands/start.js +233 -27
  116. package/dist/commands/start.js.map +1 -1
  117. package/dist/commands/team.d.ts +19 -2
  118. package/dist/commands/team.d.ts.map +1 -1
  119. package/dist/commands/team.js +93 -13
  120. package/dist/commands/team.js.map +1 -1
  121. package/dist/commands/uninstall.d.ts +20 -0
  122. package/dist/commands/uninstall.d.ts.map +1 -0
  123. package/dist/commands/uninstall.js +378 -0
  124. package/dist/commands/uninstall.js.map +1 -0
  125. package/dist/commands/wire-hooks.d.ts +16 -2
  126. package/dist/commands/wire-hooks.d.ts.map +1 -1
  127. package/dist/commands/wire-hooks.js +86 -21
  128. package/dist/commands/wire-hooks.js.map +1 -1
  129. package/dist/cta.d.ts +3 -1
  130. package/dist/cta.d.ts.map +1 -1
  131. package/dist/cta.js +11 -6
  132. package/dist/cta.js.map +1 -1
  133. package/dist/fs-atomic.d.ts.map +1 -1
  134. package/dist/fs-atomic.js +9 -2
  135. package/dist/fs-atomic.js.map +1 -1
  136. package/dist/guard-demo.d.ts +23 -0
  137. package/dist/guard-demo.d.ts.map +1 -0
  138. package/dist/guard-demo.js +34 -0
  139. package/dist/guard-demo.js.map +1 -0
  140. package/dist/guard-entry.d.ts +2 -0
  141. package/dist/guard-entry.d.ts.map +1 -0
  142. package/dist/guard-entry.js +50 -0
  143. package/dist/guard-entry.js.map +1 -0
  144. package/dist/guard-lite.d.ts +27 -0
  145. package/dist/guard-lite.d.ts.map +1 -0
  146. package/dist/guard-lite.js +274 -0
  147. package/dist/guard-lite.js.map +1 -0
  148. package/dist/guard-pack.d.ts +7 -2
  149. package/dist/guard-pack.d.ts.map +1 -1
  150. package/dist/guard-pack.js +202 -4
  151. package/dist/guard-pack.js.map +1 -1
  152. package/dist/hook-health.d.ts +7 -0
  153. package/dist/hook-health.d.ts.map +1 -0
  154. package/dist/hook-health.js +105 -0
  155. package/dist/hook-health.js.map +1 -0
  156. package/dist/investigate/detectors.d.ts +6 -0
  157. package/dist/investigate/detectors.d.ts.map +1 -1
  158. package/dist/investigate/detectors.js +10 -0
  159. package/dist/investigate/detectors.js.map +1 -1
  160. package/dist/manifest.d.ts +106 -0
  161. package/dist/manifest.d.ts.map +1 -0
  162. package/dist/manifest.js +454 -0
  163. package/dist/manifest.js.map +1 -0
  164. package/dist/mcp/http.d.ts +1 -0
  165. package/dist/mcp/http.d.ts.map +1 -1
  166. package/dist/mcp/http.js +1 -0
  167. package/dist/mcp/http.js.map +1 -1
  168. package/dist/mcp/protocol.d.ts +2 -0
  169. package/dist/mcp/protocol.d.ts.map +1 -1
  170. package/dist/mcp/protocol.js +5 -1
  171. package/dist/mcp/protocol.js.map +1 -1
  172. package/dist/mcp/stdio.d.ts +1 -0
  173. package/dist/mcp/stdio.d.ts.map +1 -1
  174. package/dist/mcp/stdio.js +1 -0
  175. package/dist/mcp/stdio.js.map +1 -1
  176. package/dist/never-list-input.d.ts +2 -0
  177. package/dist/never-list-input.d.ts.map +1 -1
  178. package/dist/never-list-input.js +30 -30
  179. package/dist/never-list-input.js.map +1 -1
  180. package/dist/notify/dispatch.d.ts +1 -1
  181. package/dist/notify/dispatch.d.ts.map +1 -1
  182. package/dist/notify/dispatch.js +4 -1
  183. package/dist/notify/dispatch.js.map +1 -1
  184. package/dist/notify/incident-templates.d.ts +23 -0
  185. package/dist/notify/incident-templates.d.ts.map +1 -0
  186. package/dist/notify/incident-templates.js +128 -0
  187. package/dist/notify/incident-templates.js.map +1 -0
  188. package/dist/notify/index.d.ts +12 -0
  189. package/dist/notify/index.d.ts.map +1 -1
  190. package/dist/notify/index.js +50 -3
  191. package/dist/notify/index.js.map +1 -1
  192. package/dist/notify/templates.d.ts +6 -0
  193. package/dist/notify/templates.d.ts.map +1 -1
  194. package/dist/notify/templates.js +12 -2
  195. package/dist/notify/templates.js.map +1 -1
  196. package/dist/offline-evaluate.d.ts.map +1 -1
  197. package/dist/offline-evaluate.js +2 -1
  198. package/dist/offline-evaluate.js.map +1 -1
  199. package/dist/parse-args.js +1 -1
  200. package/dist/parse-args.js.map +1 -1
  201. package/dist/receipt/changes.d.ts +2 -1
  202. package/dist/receipt/changes.d.ts.map +1 -1
  203. package/dist/receipt/changes.js +9 -0
  204. package/dist/receipt/changes.js.map +1 -1
  205. package/dist/receipt/dashboard.d.ts +14 -1
  206. package/dist/receipt/dashboard.d.ts.map +1 -1
  207. package/dist/receipt/dashboard.js +20 -2
  208. package/dist/receipt/dashboard.js.map +1 -1
  209. package/dist/receipt/live-server.d.ts +19 -0
  210. package/dist/receipt/live-server.d.ts.map +1 -1
  211. package/dist/receipt/live-server.js +119 -0
  212. package/dist/receipt/live-server.js.map +1 -1
  213. package/dist/receipt/receipt-css.d.ts.map +1 -1
  214. package/dist/receipt/receipt-css.js +45 -0
  215. package/dist/receipt/receipt-css.js.map +1 -1
  216. package/dist/receipt/render-receipt.d.ts +17 -0
  217. package/dist/receipt/render-receipt.d.ts.map +1 -1
  218. package/dist/receipt/render-receipt.js +479 -80
  219. package/dist/receipt/render-receipt.js.map +1 -1
  220. package/dist/receipt/search.d.ts.map +1 -1
  221. package/dist/receipt/search.js +2 -0
  222. package/dist/receipt/search.js.map +1 -1
  223. package/dist/receipt/semantic-setup.d.ts +4 -0
  224. package/dist/receipt/semantic-setup.d.ts.map +1 -1
  225. package/dist/receipt/semantic-setup.js +10 -1
  226. package/dist/receipt/semantic-setup.js.map +1 -1
  227. package/dist/receipt/share.d.ts +15 -0
  228. package/dist/receipt/share.d.ts.map +1 -1
  229. package/dist/receipt/share.js +34 -1
  230. package/dist/receipt/share.js.map +1 -1
  231. package/dist/telemetry.d.ts +17 -2
  232. package/dist/telemetry.d.ts.map +1 -1
  233. package/dist/telemetry.js +67 -7
  234. package/dist/telemetry.js.map +1 -1
  235. package/dist/trail.d.ts +45 -0
  236. package/dist/trail.d.ts.map +1 -1
  237. package/dist/trail.js +38 -0
  238. package/dist/trail.js.map +1 -1
  239. package/dist/write-journal.d.ts +39 -0
  240. package/dist/write-journal.d.ts.map +1 -0
  241. package/dist/write-journal.js +107 -0
  242. package/dist/write-journal.js.map +1 -0
  243. package/droid/README.md +1 -1
  244. package/droid/mcp.example.json +1 -1
  245. package/gemini/settings.example.json +1 -1
  246. package/grok/README.md +2 -2
  247. package/kilo/kilo.example.json +1 -1
  248. package/kimi/mcp.example.json +1 -1
  249. package/kiro/mcp.example.json +1 -1
  250. package/manifest.json +2 -2
  251. package/mastracode/mcp.example.json +1 -1
  252. package/mcp.json +1 -1
  253. package/openai/codex.config.example.toml +1 -1
  254. package/opencode/opencode.example.json +1 -1
  255. package/package.json +9 -3
  256. package/qwen/settings.example.json +1 -1
  257. package/server.json +3 -3
@@ -6,10 +6,13 @@ import { semanticInstallCommand, semanticRuntimeInstalled } from "./semantic-sta
6
6
  import { assertNoSecrets, clip, stripEscapes } from "../dash/render.js";
7
7
  import { clipMultiline, DIFF_MAX_TOTAL_CHARS } from "../diff-preview.js";
8
8
  import { DOMAIN_LABELS, domainOrder } from "../certify/render.js";
9
- import { productLabel, eventsSince, readTrail } from "../trail.js";
9
+ import { productLabel, eventsSince, readTrail, countOutcomes, outcomeOf, wouldHoldInStrict } from "../trail.js";
10
+ import { eventIdOf, isNearMiss } from "../card.js";
11
+ import { readCredentials } from "../login/credentials.js";
10
12
  import { mcpServerLabel, parseMcpToolId } from "../mcp-servers.js";
11
13
  import { loadInvestigateLastRun, } from "../investigate/last-run.js";
12
14
  import { readNotifyLog, summarizeNotifyLog, } from "../notify/log.js";
15
+ import { readAlertsState } from "../alerts/state.js";
13
16
  import { readOtelStats } from "../otel/stats.js";
14
17
  import { loadCertifyCard, loadDiscoveryCard, loadIdentity, loadOrrCard, loadSandboxes, loadShowbackCard, loadWiredHosts, } from "./enrich.js";
15
18
  import { SHOWBACK_DISCLAIMER, } from "../showback/cost-per-task.js";
@@ -18,6 +21,7 @@ import { computeDashboard } from "./dashboard.js";
18
21
  import { buildChangesModel } from "./changes.js";
19
22
  import { GATE_NOT_SETUP_QUICKSTART, loadGatePage, toolNameFromToolId, toolServerPrefix, } from "./gate-page.js";
20
23
  import { receiptCss } from "./receipt-css.js";
24
+ import { DEMO_CALLS, PROMISE } from "../guard-demo.js";
21
25
  import { applyChipFilters, rankEvents } from "./search.js";
22
26
  import { emptyOpenshellCard, loadOpenshellCard } from "./openshell-card.js";
23
27
  import { readIocResult } from "../ioc/result.js";
@@ -62,6 +66,11 @@ function mdFence(content) {
62
66
  function mdInline(s) {
63
67
  return stripEscapes(s).replace(/`/g, "'");
64
68
  }
69
+ /**
70
+ * Counts for the report headline. A row with an outcome is counted by what happened to the call (`outcomes`); only a
71
+ * row without one (written before outcomes existed) is counted by its stored verdict (allow, deny, require).
72
+ * `never` is a policy overlay and counts every row.
73
+ */
65
74
  function countVerdicts(events) {
66
75
  let allow = 0;
67
76
  let deny = 0;
@@ -71,6 +80,8 @@ function countVerdicts(events) {
71
80
  const v = e.verdict.toUpperCase();
72
81
  if (e.neverEvent || e.reasonCode === "NEVER_EVENT")
73
82
  never++;
83
+ if (outcomeOf(e))
84
+ continue;
74
85
  if (v === "ALLOW")
75
86
  allow++;
76
87
  else if (v === "DENY")
@@ -78,7 +89,55 @@ function countVerdicts(events) {
78
89
  else if (v === "REQUIRE_APPROVE")
79
90
  require++;
80
91
  }
81
- return { allow, deny, require, never };
92
+ return { allow, deny, require, never, outcomes: countOutcomes(events) };
93
+ }
94
+ const STRICT_NOTE = "would hold in strict mode";
95
+ /** One line under the headline: how to turn strict mode on. Shown only when a call ran that strict mode would hold (STRICT_NOTE is the row note). */
96
+ function strictHowTo(wouldHold, code) {
97
+ return `${wouldHold} ${wouldHold === 1 ? "call that ran" : "calls that ran"} would hold in strict mode. To turn strict mode on, add ${code("--strict")} to the ${code("kya hook")} command in your tool's hook settings.`;
98
+ }
99
+ /** Headline text for the Markdown report: "Ran 11, Blocked 5, Held 0". Error is listed only when it is not 0. */
100
+ function outcomeHeadline(o) {
101
+ return `Ran ${o.ran}, Blocked ${o.blocked}, Held ${o.held}${o.error > 0 ? `, Error ${o.error}` : ""}`;
102
+ }
103
+ const OUTCOME_WORD = { ran: "RAN", blocked: "BLOCKED", held: "HELD", error: "ERROR" };
104
+ /** A short note for a call that ran although policy said otherwise (REQUIRE_APPROVE, or DENY in shadow mode). */
105
+ function ranNote(e) {
106
+ if (wouldHoldInStrict(e))
107
+ return STRICT_NOTE;
108
+ if (outcomeOf(e) === "ran" && e.verdict.toUpperCase() === "DENY")
109
+ return "would block (shadow mode)";
110
+ return undefined;
111
+ }
112
+ /** Feed order: blocked rows (newest first) above everything else (newest first). Rows without an outcome sort as before. */
113
+ function compareFeed(a, b) {
114
+ const rank = (e) => (outcomeOf(e) === "blocked" ? 0 : 1);
115
+ return rank(a) - rank(b) || b.ts.localeCompare(a.ts);
116
+ }
117
+ /** Key for rows that may collapse. Only rows with an outcome collapse; a blocked row, or one with a change preview, never does. */
118
+ function collapseKey(e) {
119
+ const o = outcomeOf(e);
120
+ if (!o || o === "blocked" || e.diffPreview?.trim())
121
+ return undefined;
122
+ return JSON.stringify([e.toolId, o, e.verdict, e.reasonCode, e.summary ?? "", e.targetPath ?? "", e.argsHash ?? "", e.sessionId, e.product ?? "", e.project ?? "", e.mode]);
123
+ }
124
+ /** Merge identical consecutive rows ("shell echo, 10 times"). Input order is kept. */
125
+ export function collapseFeedRows(ordered) {
126
+ const out = [];
127
+ for (const e of ordered) {
128
+ const key = collapseKey(e);
129
+ const last = out[out.length - 1];
130
+ if (last && key !== undefined && last.key === key)
131
+ last.count++;
132
+ else
133
+ out.push({ event: e, count: 1, key });
134
+ }
135
+ return out.map(({ event, count }) => ({ event, count }));
136
+ }
137
+ /** "shell echo (+1 args), 3 times" or just "3 times" when the row has no summary. */
138
+ function repeatText(e, count) {
139
+ const base = e.summary?.trim();
140
+ return `${base ? `${base}, ` : ""}${count} times`;
82
141
  }
83
142
  function relativeTime(ts, nowMs) {
84
143
  const t = Date.parse(ts);
@@ -141,6 +200,21 @@ function verdictWord(verdict) {
141
200
  return "REVIEW";
142
201
  return v;
143
202
  }
203
+ /** Row tone: by outcome when the row has one (a call that ran is ok, even if policy said review), else by verdict. */
204
+ function rowTone(e) {
205
+ switch (outcomeOf(e)) {
206
+ case "ran": return "ok";
207
+ case "blocked": return "bad";
208
+ case "held": return "warn";
209
+ case "error": return "mute";
210
+ default: return tone(e.verdict);
211
+ }
212
+ }
213
+ /** Row label: RAN, BLOCKED, HELD or ERROR when the row has an outcome, else the verdict word as before. */
214
+ function rowWord(e) {
215
+ const o = outcomeOf(e);
216
+ return o ? OUTCOME_WORD[o] : verdictWord(e.verdict);
217
+ }
144
218
  /** Pure rollup of trail events for the report's stat chips and sections. */
145
219
  export function aggregateEvents(events) {
146
220
  const modes = { observe: 0, hold: 0, offline: 0 };
@@ -181,8 +255,15 @@ export function aggregateEvents(events) {
181
255
  s.events++;
182
256
  if (e.neverEvent || e.reasonCode === "NEVER_EVENT")
183
257
  s.never++;
258
+ const o = outcomeOf(e);
184
259
  const v = e.verdict.toUpperCase();
185
- if (v === "DENY")
260
+ if (o) {
261
+ if (o === "blocked")
262
+ s.deny++;
263
+ else if (o === "held")
264
+ s.hold++;
265
+ }
266
+ else if (v === "DENY")
186
267
  s.deny++;
187
268
  else if (v === "REQUIRE_APPROVE")
188
269
  s.hold++;
@@ -274,6 +355,7 @@ export function buildReceiptModel(sessionId, events, extras) {
274
355
  liveToken: extras?.liveToken,
275
356
  searchQuery: extras?.searchQuery,
276
357
  identity: extras?.identity,
358
+ planeConfigured: extras?.planeConfigured,
277
359
  sandboxes: extras?.sandboxes,
278
360
  openshell: extras?.openshell,
279
361
  wiredHosts: extras?.wiredHosts,
@@ -285,6 +367,7 @@ export function buildReceiptModel(sessionId, events, extras) {
285
367
  discovery: extras?.discovery,
286
368
  investigate: extras?.investigate,
287
369
  alerts: extras?.alerts,
370
+ incidentAlerts: extras?.incidentAlerts,
288
371
  otel: extras?.otel,
289
372
  ioc: extras?.ioc,
290
373
  posture: extras?.posture,
@@ -304,6 +387,7 @@ export function buildWindowReceiptModel(events, days, extras) {
304
387
  liveToken: extras?.liveToken,
305
388
  searchQuery: extras?.searchQuery,
306
389
  identity: extras?.identity,
390
+ planeConfigured: extras?.planeConfigured,
307
391
  sandboxes: extras?.sandboxes,
308
392
  openshell: extras?.openshell,
309
393
  wiredHosts: extras?.wiredHosts,
@@ -315,6 +399,7 @@ export function buildWindowReceiptModel(events, days, extras) {
315
399
  discovery: extras?.discovery,
316
400
  investigate: extras?.investigate,
317
401
  alerts: extras?.alerts,
402
+ incidentAlerts: extras?.incidentAlerts,
318
403
  otel: extras?.otel,
319
404
  ioc: extras?.ioc,
320
405
  posture: extras?.posture,
@@ -339,6 +424,7 @@ export function loadReceiptModel(input) {
339
424
  liveToken: input.liveToken,
340
425
  searchQuery: input.searchQuery,
341
426
  identity: loadIdentity(input.cwd),
427
+ planeConfigured: isPlaneConfigured(loadIdentity(input.cwd), env),
342
428
  sandboxes: loadSandboxes(input.cwd),
343
429
  openshell: loadOpenshellCard({ cwd: input.cwd, events: importEvents, env, certify }),
344
430
  wiredHosts: loadWiredHosts(input.cwd),
@@ -350,6 +436,7 @@ export function loadReceiptModel(input) {
350
436
  discovery: loadDiscoveryCard(env),
351
437
  investigate: loadInvestigateLastRun(env),
352
438
  alerts: summarizeNotifyLog(readNotifyLog(env)),
439
+ incidentAlerts: readAlertsState(env),
353
440
  otel: readOtelStats(env),
354
441
  ioc: readIocResult(env),
355
442
  posture: postureCard(env),
@@ -375,6 +462,7 @@ function filterDataAttrs(e, sessionId) {
375
462
  const project = e.project?.trim();
376
463
  const server = parseMcpToolId(e.toolId);
377
464
  return (`data-verdict="${esc(e.verdict.toUpperCase())}"` +
465
+ (outcomeOf(e) ? ` data-outcome="${outcomeOf(e)}"` : "") +
378
466
  (never ? ` data-never="1"` : "") +
379
467
  ` data-mode="${esc(e.mode)}"` +
380
468
  ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
@@ -410,6 +498,18 @@ function noMatchHint(query, env = process.env) {
410
498
  return (`${base} Plain-language search is off, so only exact words match. ` +
411
499
  `Turn it on once with <code>${esc(semanticInstallCommand(env))}</code>, then search again.`);
412
500
  }
501
+ /**
502
+ * "Make card" next to a stopped row (G5). Only a denied call gets it. The live report gets a button that calls the local
503
+ * /action/card; a static file has no server, so it shows the CLI command. Never in the deny text the agent reads.
504
+ */
505
+ function cardControl(e, live) {
506
+ if (!isNearMiss(e))
507
+ return "";
508
+ const id = esc(eventIdOf(e));
509
+ return live
510
+ ? `<span class="card-ctl"><button type="button" class="stat" data-card-id="${id}">Make card</button><span class="mute small card-result" role="status" aria-live="polite"></span></span>`
511
+ : `<span class="card-ctl mute small">Make card: <code>kya receipt --card --id ${id}</code></span>`;
512
+ }
413
513
  function renderFeed(events, nowMs, live, preserveOrder, noMatch) {
414
514
  if (events.length === 0) {
415
515
  if (noMatch)
@@ -419,19 +519,34 @@ function renderFeed(events, nowMs, live, preserveOrder, noMatch) {
419
519
  : "No events yet - evaluate a tool call with <code>kya wrap</code>, then regenerate this receipt.";
420
520
  return `<p class="empty">${hint}</p>`;
421
521
  }
422
- const ordered = preserveOrder ? [...events] : [...events].sort((a, b) => b.ts.localeCompare(a.ts));
522
+ const ordered = preserveOrder ? [...events] : [...events].sort(compareFeed);
523
+ // Search ranking keeps its order and never collapses; the plain feed merges identical consecutive rows.
524
+ const rows = preserveOrder ? ordered.map((event) => ({ event, count: 1 })) : collapseFeedRows(ordered);
423
525
  const parts = [];
424
526
  let lastDay = "";
425
- for (const e of ordered) {
426
- const key = localDayKey(e.ts);
427
- if (key !== lastDay) {
428
- lastDay = key;
429
- parts.push(`<div class="day" role="presentation">${esc(dayLabel(key, nowMs))}</div>`);
527
+ let pinnedHeading = false;
528
+ for (const { event: e, count } of rows) {
529
+ // Blocked rows sit above everything else, under one heading, so they never hide below repeated rows.
530
+ const pinned = !preserveOrder && outcomeOf(e) === "blocked";
531
+ if (pinned) {
532
+ if (!pinnedHeading) {
533
+ pinnedHeading = true;
534
+ parts.push(`<div class="day" role="presentation">Blocked</div>`);
535
+ }
430
536
  }
431
- const t = tone(e.verdict);
537
+ else {
538
+ const key = localDayKey(e.ts);
539
+ if (key !== lastDay) {
540
+ lastDay = key;
541
+ parts.push(`<div class="day" role="presentation">${esc(dayLabel(key, nowMs))}</div>`);
542
+ }
543
+ }
544
+ const t = rowTone(e);
432
545
  const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
433
- const summary = e.summary?.trim()
434
- ? `<div class="summary">${esc(e.summary.trim())}</div>`
546
+ const note = ranNote(e);
547
+ const summaryText = count > 1 ? repeatText(e, count) : e.summary?.trim();
548
+ const summary = summaryText
549
+ ? `<div class="summary">${esc(summaryText)}</div>`
435
550
  : "";
436
551
  const preview = e.diffPreview?.trim()
437
552
  ? `<details class="diff"><summary>Change preview</summary><pre>${esc(e.diffPreview.trim())}</pre></details>`
@@ -446,9 +561,10 @@ function renderFeed(events, nowMs, live, preserveOrder, noMatch) {
446
561
  <div class="rail" aria-hidden="true"><span class="tick"></span></div>
447
562
  <div class="main">
448
563
  <div class="top">
449
- <span class="verdict">${esc(verdictWord(e.verdict))}</span>${modeBadge(e.mode)}
564
+ <span class="verdict">${esc(rowWord(e))}</span>${note ? `<span class="strict-note">${esc(note)}</span>` : ""}${modeBadge(e.mode)}
450
565
  <code class="tool">${esc(e.toolId)}</code>
451
566
  <time class="when" datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(clock(e.ts))} <span class="rel">${esc(relativeTime(e.ts, nowMs))}</span></time>
567
+ ${cardControl(e, live)}
452
568
  </div>
453
569
  ${summary}
454
570
  ${preview}
@@ -484,11 +600,11 @@ function changesPanel(changes, nowMs) {
484
600
  .map((f) => {
485
601
  const entries = f.entries
486
602
  .map((e) => {
487
- const t = tone(e.verdict);
603
+ const t = rowTone(e);
488
604
  const dataAttrs = filterDataAttrs(e, s.sessionId);
489
605
  return ` <div class="chg-entry ${t}${e.never ? " never" : ""}" ${dataAttrs}>
490
606
  <div class="top">
491
- <span class="verdict">${esc(verdictWord(e.verdict))}</span>${modeBadge(e.mode)}
607
+ <span class="verdict">${esc(rowWord(e))}</span>${modeBadge(e.mode)}
492
608
  <code class="tool">${esc(e.toolId)}</code>
493
609
  <time class="when" datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(clock(e.ts))} <span class="rel">${esc(relativeTime(e.ts, nowMs))}</span></time>
494
610
  </div>
@@ -517,13 +633,41 @@ ${files}
517
633
  })
518
634
  .join("\n");
519
635
  }
520
- function identityLine(identity) {
636
+ /** Default base URL `kya init` writes. It is a placeholder, not a plane that runs. */
637
+ const DEFAULT_PLANE_URL = "http://127.0.0.1:8090";
638
+ /** A control plane counts as configured with an API key, saved hosted login, or a base URL other than the init default. */
639
+ export function isPlaneConfigured(identity, env = process.env) {
640
+ if (env.KYA_API_KEY?.trim() || env.SHIELD_API_KEY?.trim() || env.KYA_BASE_URL?.trim())
641
+ return true;
642
+ if (readCredentials(env))
643
+ return true;
644
+ const base = identity?.baseUrl?.trim().replace(/\/+$/, "");
645
+ return Boolean(base && base !== DEFAULT_PLANE_URL);
646
+ }
647
+ /** Who the report is for. Host and plane address show only when a plane is configured. */
648
+ function identityBits(identity, planeConfigured) {
521
649
  if (!identity)
522
- return "";
650
+ return [];
651
+ // Hidden fields are still checked: a credential in the saved base URL must stop the render, shown or not.
652
+ assertNoSecrets([identity.agentName, identity.agentId, identity.host, identity.baseUrl].filter(Boolean).join(" · "));
523
653
  const nameId = identity.agentName && identity.agentId
524
654
  ? `${identity.agentName}/${identity.agentId}`
525
655
  : (identity.agentName ?? identity.agentId);
526
- const bits = [nameId, identity.host, identity.baseUrl].filter((b) => typeof b === "string" && b.trim().length > 0);
656
+ return [nameId, ...(planeConfigured ? [identity.host, identity.baseUrl] : [])].filter((b) => typeof b === "string" && b.trim().length > 0);
657
+ }
658
+ /**
659
+ * Header block of the live report (F4): the "Share this report" button and the place for its result. The button
660
+ * calls POST /action/share (the same redacted payload as `kya receipt --share`). Static files do not get it:
661
+ * there is no server to answer.
662
+ */
663
+ function shareHeaderBlock() {
664
+ return `<div class="share-row" id="share-report" style="margin-top:0.75rem">
665
+ <button type="button" class="stat" id="share-report-btn">Share this report</button>
666
+ <div id="share-report-result" role="status" aria-live="polite"></div>
667
+ </div>`;
668
+ }
669
+ function identityLine(identity, planeConfigured) {
670
+ const bits = identityBits(identity, planeConfigured);
527
671
  if (bits.length === 0)
528
672
  return "";
529
673
  // baseUrl is inert text here - never an anchor.
@@ -641,7 +785,20 @@ function dashboardPanel(db) {
641
785
  </button>`;
642
786
  // Zero-count rows are hidden, consistent with the header chips.
643
787
  const mixRow = (label, group, value, count, fill) => count > 0 ? barRow(label, group, value, pct(count), `${pct(count)}%`, fill) : "";
644
- const mixCard = `<div class="db-card">
788
+ const om = db.outcomeMix;
789
+ const mixCard = om.recorded > 0
790
+ ? `<div class="db-card">
791
+ <h3>Outcome mix</h3>
792
+ ${mixRow("Ran", "outcome", "ran", om.ran, "ok")}
793
+ ${mixRow("Blocked", "outcome", "blocked", om.blocked, "bad")}
794
+ ${mixRow("Held", "outcome", "held", om.held, "warn")}
795
+ ${mixRow("Error", "outcome", "error", om.error, "warn")}
796
+ ${mixRow("Earlier: Allow", "verdict", "ALLOW", om.legacyMix.allow, "ok")}
797
+ ${mixRow("Earlier: Review", "verdict", "REQUIRE_APPROVE", om.legacyMix.review, "warn")}
798
+ ${mixRow("Earlier: Deny", "verdict", "DENY", om.legacyMix.deny, "bad")}
799
+ ${mixRow("Never", "never", "1", db.verdictMix.never, "bad")}
800
+ </div>`
801
+ : `<div class="db-card">
645
802
  <h3>Verdict mix</h3>
646
803
  ${mixRow("Allow", "verdict", "ALLOW", db.verdictMix.allow, "ok")}
647
804
  ${mixRow("Review", "verdict", "REQUIRE_APPROVE", db.verdictMix.review, "warn")}
@@ -1374,6 +1531,46 @@ function kpiTile(label, value, cls = "") {
1374
1531
  const toned = typeof value === "number" && value > 0 && cls ? ` ${cls}` : "";
1375
1532
  return `<div class="kpi"><span class="kpi-num${toned}">${v}</span><span class="kpi-lab">${label}</span></div>`;
1376
1533
  }
1534
+ /** Rows the hook stopped (outcome "blocked", F3), newest first. Rows with no outcome are not counted. */
1535
+ export function blockedCalls(events) {
1536
+ return events.filter((e) => outcomeOf(e) === "blocked").sort((a, b) => b.ts.localeCompare(a.ts));
1537
+ }
1538
+ const BLOCKED_ROWS_SHOWN = 5;
1539
+ /**
1540
+ * First panel of the report (guard first): how many calls the guard stopped and the last five.
1541
+ * With none blocked it shows the three built-in demo calls, labelled as a demo, never as activity.
1542
+ */
1543
+ function heroBlockedPanel(events, nowMs, windowLabel, live) {
1544
+ const blocked = blockedCalls(events);
1545
+ if (blocked.length === 0) {
1546
+ const demo = DEMO_CALLS.map((c) => ` <li><code>${esc(c.label)}</code><span class="blk-what">${esc(c.what)}</span><span class="pill">demo</span></li>`).join("\n");
1547
+ return `<section class="panel hero-blocked demo" id="frag-hero-blocked" aria-label="Blocked">
1548
+ <h2>Blocked <span class="pill">demo</span></h2>
1549
+ <p class="blk-lead"><span class="blk-num">0</span> calls blocked ${esc(windowLabel)}. Demo: three calls your agent could make, and the guard stops all three.</p>
1550
+ <ul class="blk-rows">
1551
+ ${demo}
1552
+ </ul>
1553
+ <p class="mute small">These rows are samples, not your activity. Real blocked calls replace them here.</p>
1554
+ </section>`;
1555
+ }
1556
+ const rows = blocked
1557
+ .slice(0, BLOCKED_ROWS_SHOWN)
1558
+ .map((e) => {
1559
+ const what = e.summary?.trim() ? esc(clip(e.summary.trim(), 140)) : "";
1560
+ const rule = e.neverRule ? `<span class="mute small">rule <code>${esc(e.neverRule)}</code></span>` : "";
1561
+ return ` <li><code>${esc(e.toolId)}</code><span class="blk-what">${what}</span>${rule}<span class="mute small">${esc(relativeTime(e.ts, nowMs))}</span>${cardControl(e, live)}</li>`;
1562
+ })
1563
+ .join("\n");
1564
+ const more = blocked.length > BLOCKED_ROWS_SHOWN ? `<p class="mute small">Showing the last ${BLOCKED_ROWS_SHOWN} of ${blocked.length}. The Activity tab lists every call.</p>` : "";
1565
+ return `<section class="panel hero-blocked" id="frag-hero-blocked" aria-label="Blocked">
1566
+ <h2>Blocked</h2>
1567
+ <p class="blk-lead"><span class="blk-num bad">${blocked.length}</span> ${blocked.length === 1 ? "call" : "calls"} blocked ${esc(windowLabel)}.</p>
1568
+ <ul class="blk-rows">
1569
+ ${rows}
1570
+ </ul>
1571
+ ${more}
1572
+ </section>`;
1573
+ }
1377
1574
  /**
1378
1575
  * Hero Certify panel: purpose-built rich card for the live Agent Trust
1379
1576
  * Baseline. The section carries the result state (`hero-certify pass|gap|
@@ -1536,6 +1733,23 @@ function verdictsHeroCard(c, total) {
1536
1733
  // raw event count keeps every bar inside the base the Overview card uses.
1537
1734
  const pct = (n) => (total > 0 ? Math.round((n / total) * 100) : 0);
1538
1735
  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>`;
1736
+ const o = c.outcomes;
1737
+ if (o.recorded > 0) {
1738
+ const row = (label, n, fill) => (n > 0 ? mixRow(label, n, fill) : "");
1739
+ return `<section class="panel" aria-label="Verdicts">
1740
+ <h2>Decisions</h2>
1741
+ <div class="mix">
1742
+ ${mixRow("Ran", o.ran, "ok")}
1743
+ ${mixRow("Blocked", o.blocked, "bad")}
1744
+ ${mixRow("Held", o.held, "warn")}
1745
+ ${row("Error", o.error, "warn")}
1746
+ ${row("Earlier: Allow", c.allow, "ok")}
1747
+ ${row("Earlier: Deny", c.deny, "bad")}
1748
+ ${row("Earlier: Review", c.require, "warn")}
1749
+ ${mixRow("Never", c.never, "bad")}
1750
+ </div>
1751
+ </section>`;
1752
+ }
1539
1753
  return `<section class="panel" aria-label="Verdicts">
1540
1754
  <h2>Decisions</h2>
1541
1755
  <div class="mix">
@@ -1769,6 +1983,29 @@ ${card.findings
1769
1983
  <p class="mute small">ran ${esc(relativeTime(card.ranAt, nowMs))} · window ${card.windowDays}d · <code>kya investigate</code> for the full report</p>
1770
1984
  </section>`;
1771
1985
  }
1986
+ /** Overview list of incidents that alerted, newest first. Renders nothing until one has alerted. */
1987
+ function incidentAlertsPanel(entries, nowMs) {
1988
+ if (!entries || entries.length === 0)
1989
+ return "";
1990
+ const rows = [...entries]
1991
+ .reverse()
1992
+ .slice(0, 5)
1993
+ .map((e) => {
1994
+ const where = e.status === "sent" && e.delivered > 0 ? `sent to ${e.delivered}` : e.status === "sent" ? "report only" : "not delivered";
1995
+ const tri = e.triage
1996
+ ? `<br><span class="mute small">triage (local model, advisory): ${esc(e.triage.category.id)} ${Math.round(e.triage.category.p * 100)}% · ${esc(e.triage.urgency.id)} · ${esc(e.triage.action.id)} · harmless ${Math.round(e.triage.benign * 100)}%</span>`
1997
+ : "";
1998
+ return ` <li>${severityPill(e.severity)} ${esc(e.title)} <span class="mute">${esc(where)} · <time datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(relativeTime(e.ts, nowMs))}</time></span>${tri}</li>`;
1999
+ })
2000
+ .join("\n");
2001
+ return `<section class="panel" aria-label="Incident alerts">
2002
+ <h2>Incident alerts</h2>
2003
+ <ul class="rows">
2004
+ ${rows}
2005
+ </ul>
2006
+ <p class="mute small">incidents that raised an alert · <code>kya alerts status</code> · local KYA is advisory</p>
2007
+ </section>`;
2008
+ }
1772
2009
  /**
1773
2010
  * System-tab Alerts panel: per-target delivery rollup from the notify ledger.
1774
2011
  * With no deliveries yet the panel still renders as an empty state (config
@@ -1877,7 +2114,7 @@ export function renderFeedPage(events, filters, page, nowMs, live, query, embedd
1877
2114
  total = ranked.length;
1878
2115
  }
1879
2116
  else {
1880
- const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
2117
+ const sorted = [...events].sort(compareFeed);
1881
2118
  ordered = applyChipFilters(sorted, filters);
1882
2119
  total = ordered.length;
1883
2120
  }
@@ -1921,6 +2158,21 @@ const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
1921
2158
  : `<p class="mute small zone-empty">${empty}</p>`;
1922
2159
  /** Inner markup of the Activity counts bar (verdict buttons + mode/plane chips). */
1923
2160
  function statCountsInner(c, chips) {
2161
+ const o = c.outcomes;
2162
+ if (o.recorded > 0) {
2163
+ const btn = (label, n, group, value, cls, title) => `<button type="button" class="stat${cls ? ` ${cls}` : ""}" data-fgroup="${group}" data-fvalue="${value}" aria-pressed="false" title="Filter: ${title}">${label}<b>${n}</b></button>`;
2164
+ // Rows from before outcomes existed keep their verdict counts, labelled as earlier rows.
2165
+ const earlier = (label, n, value, cls) => n > 0 ? btn(`Earlier: ${label}`, n, "verdict", value, cls, `${label} (rows with no outcome recorded)`) : "";
2166
+ const howTo = o.wouldHold > 0 ? `\n <span class="strict-howto">${strictHowTo(o.wouldHold, (t) => `<code>${t}</code>`)}</span>` : "";
2167
+ return `${btn("Ran", o.ran, "outcome", "ran", "ok", "Ran")}
2168
+ ${btn("Blocked", o.blocked, "outcome", "blocked", "bad", "Blocked")}
2169
+ ${btn("Held", o.held, "outcome", "held", "warn", "Held")}
2170
+ ${o.error > 0 ? btn("Error", o.error, "outcome", "error", "", "Error") : ""}
2171
+ ${earlier("Allow", c.allow, "ALLOW", "ok")}${earlier("Deny", c.deny, "DENY", "bad")}${earlier("Review", c.require, "REQUIRE_APPROVE", "warn")}
2172
+ <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
2173
+ ${chips.modes}${chips.planes}${howTo}
2174
+ <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>`;
2175
+ }
1924
2176
  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>
1925
2177
  <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
1926
2178
  <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>
@@ -1948,7 +2200,7 @@ export function buildLiveFragments(model, livePaged) {
1948
2200
  chipProducts: chips.products,
1949
2201
  chipProjects: chips.projects,
1950
2202
  chipServers: chips.servers,
1951
- heroCertify: heroCertifyPanel(model.certify),
2203
+ heroBlocked: heroBlockedPanel(events, nowMs, `in the ${model.rangeLabel}`, model.live),
1952
2204
  heroTiles: [
1953
2205
  verdictsHeroCard(c, events.length),
1954
2206
  activityHeroCard(agg, dashboard, events.length),
@@ -1956,18 +2208,24 @@ export function buildLiveFragments(model, livePaged) {
1956
2208
  importHeroCard(model.imports, nowMs, livePaged),
1957
2209
  discoveryHeroCard(model.discovery, nowMs, livePaged),
1958
2210
  investigateHeroCard(model.investigate, nowMs, livePaged),
1959
- showbackHeroCard(model.showback, events),
1960
2211
  ].join("\n "),
1961
- overviewPanels: zoneBody([dashboardPanel(dashboard), sessionsPanel(agg, nowMs, changes), reasonsPanel(agg)], "No activity in this window yet - analytics appear once events land."),
1962
- certifyBody: zoneBody([model.certify ? certifyDetailPanel(model.certify.requirements) : ""], "No check yet - use your coding tools with kya connected, then run kya certify."),
2212
+ showbackPanel: showbackHeroCard(model.showback, events),
2213
+ connectedBody: zoneBody([wiredHostsPanel(model.wiredHosts)], "No coding agent is connected yet. Run kya connect &lt;host&gt; to add one."),
2214
+ overviewPanels: zoneBody([incidentAlertsPanel(model.incidentAlerts, nowMs), dashboardPanel(dashboard), sessionsPanel(agg, nowMs, changes), reasonsPanel(agg)], "No activity in this window yet - analytics appear once events land."),
2215
+ certifyBody: zoneBody([
2216
+ heroCertifyPanel(model.certify),
2217
+ model.certify
2218
+ ? certifyDetailPanel(model.certify.requirements)
2219
+ : `<p class="mute small zone-empty">The full rule table appears here after the first check.</p>`,
2220
+ ], "No check yet - use your coding tools with kya connected, then run kya certify."),
1963
2221
  orrPanel: orrPanel(model.orr, nowMs, livePaged),
1964
2222
  changesList: changesPanel(changes, nowMs),
1965
2223
  },
1966
2224
  nav: {
1967
- overview: events.length,
2225
+ overview: blockedCalls(events).length,
1968
2226
  activity: events.length,
1969
2227
  changes: changes.fileCount,
1970
- certify: model.certify?.gap ?? null,
2228
+ certify: null,
1971
2229
  orr: model.orr ? model.orr.scorecards.fail : null,
1972
2230
  },
1973
2231
  };
@@ -2371,8 +2629,10 @@ export function renderReceiptHtml(model) {
2371
2629
  chipProducts: 'chip-products',
2372
2630
  chipProjects: 'chip-projects',
2373
2631
  chipServers: 'chip-servers',
2374
- heroCertify: 'frag-hero-certify',
2632
+ heroBlocked: 'frag-hero-blocked',
2375
2633
  heroTiles: 'frag-hero-tiles',
2634
+ showbackPanel: 'frag-showback',
2635
+ connectedBody: 'frag-connected',
2376
2636
  overviewPanels: 'frag-overview-panels',
2377
2637
  certifyBody: 'frag-certify',
2378
2638
  orrPanel: 'frag-orr',
@@ -2589,6 +2849,25 @@ export function renderReceiptHtml(model) {
2589
2849
  try { localStorage.setItem('kya-theme', root.getAttribute('data-theme')); } catch(e){}
2590
2850
  });
2591
2851
  })();
2852
+ </script>`;
2853
+ // The "More" menu: closed by default, remembered per browser, and open when the page was reached through
2854
+ // a link to a tab inside it (a hash such as #certify). Without JS or localStorage the page still works.
2855
+ const moreScript = `<script>
2856
+ (function(){
2857
+ var more = document.getElementById('nav-more');
2858
+ if (!more) return;
2859
+ var inside = false;
2860
+ try { inside = !!(location.hash && more.querySelector('a[href="' + location.hash + '"]')); } catch(e){}
2861
+ var stored = null;
2862
+ try { stored = localStorage.getItem('kya-nav-more'); } catch(e){}
2863
+ if (inside || stored === 'open') more.open = true;
2864
+ more.addEventListener('toggle', function(){
2865
+ try { localStorage.setItem('kya-nav-more', more.open ? 'open' : 'closed'); } catch(e){}
2866
+ });
2867
+ window.addEventListener('hashchange', function(){
2868
+ try { if (location.hash && more.querySelector('a[href="' + location.hash + '"]')) more.open = true; } catch(e){}
2869
+ });
2870
+ })();
2592
2871
  </script>`;
2593
2872
  // Gateway interactive controls: playground evaluator, quick actions, and the
2594
2873
  // sidebar stop-report link. Only wired when the page is served by the live
@@ -2748,6 +3027,68 @@ export function renderReceiptHtml(model) {
2748
3027
  });
2749
3028
  });
2750
3029
 
3030
+ // The card control next to a stopped row. Delegated: the Blocked panel is replaced on every live refresh.
3031
+ document.addEventListener('click', function(ev){
3032
+ var b = ev.target && ev.target.closest ? ev.target.closest('[data-card-id]') : null;
3033
+ if (!b) return;
3034
+ var out = b.parentNode.querySelector('.card-result');
3035
+ b.disabled = true;
3036
+ out.textContent = 'Making the card...';
3037
+ post('/action/card', { id: b.getAttribute('data-card-id') }).then(parseJson).then(function(d){
3038
+ out.textContent = d && d.ok ? 'Saved ' + (d.png || d.svg) + '. Caption: ' + d.caption.replace(/\\n/g, ' ') : 'No card: ' + (d && d.error ? d.error : 'unknown error');
3039
+ b.disabled = false;
3040
+ }).catch(function(err){ out.textContent = 'No card: ' + err.message; b.disabled = false; });
3041
+ });
3042
+
3043
+ var shareBtn = document.getElementById('share-report-btn');
3044
+ if (shareBtn) {
3045
+ var shareOut = document.getElementById('share-report-result');
3046
+ var shareFail = function(msg){
3047
+ setResult(shareOut, '<p>Could not share the report: ' + esc(msg) + '. The report still works. Try again when you are online.</p>', true);
3048
+ shareBtn.disabled = false;
3049
+ };
3050
+ var copyText = function(text, field, btn){
3051
+ var done = function(){ btn.textContent = 'Copied'; };
3052
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3053
+ navigator.clipboard.writeText(text).then(done, function(){ field.select(); btn.textContent = 'Press Ctrl+C to copy'; });
3054
+ } else { field.select(); btn.textContent = 'Press Ctrl+C to copy'; }
3055
+ };
3056
+ shareBtn.addEventListener('click', function(){
3057
+ shareBtn.disabled = true;
3058
+ shareOut.textContent = 'Sharing...';
3059
+ post('/action/share', {}).then(parseJson).then(function(d){
3060
+ if (!d || !d.ok) { shareFail(d && d.error ? d.error : 'unknown error'); return; }
3061
+ // Show and open only an http(s) link. share.ts already checked it is on the server that was asked.
3062
+ if (!/^https?:/.test(String(d.url))) { shareFail('the server sent a link that is not a web address'); return; }
3063
+ shareOut.textContent = '';
3064
+ var label = document.createElement('p');
3065
+ label.textContent = 'Public link. Send it to a colleague:';
3066
+ var field = document.createElement('input');
3067
+ field.type = 'text'; field.readOnly = true; field.id = 'share-url'; field.value = d.url; field.size = 44;
3068
+ var copy = document.createElement('button');
3069
+ copy.type = 'button'; copy.className = 'stat'; copy.id = 'share-copy'; copy.textContent = 'Copy link';
3070
+ copy.addEventListener('click', function(){ copyText(d.url, field, copy); });
3071
+ shareOut.appendChild(label); shareOut.appendChild(field); shareOut.appendChild(copy);
3072
+ var claim = document.createElement('p');
3073
+ var a = document.createElement('a');
3074
+ a.id = 'share-claim'; a.href = d.url; a.target = '_blank'; a.rel = 'noopener';
3075
+ a.textContent = 'Open the page to claim it, keep it and get a weekly summary';
3076
+ claim.appendChild(a);
3077
+ shareOut.appendChild(claim);
3078
+ var connect = document.createElement('p');
3079
+ connect.id = 'share-connect';
3080
+ connect.textContent = 'To connect this computer to your account, run kya login.';
3081
+ shareOut.appendChild(connect);
3082
+ if (d.expiresAt) {
3083
+ var exp = document.createElement('p');
3084
+ exp.textContent = 'If nobody claims the report, it is deleted on ' + String(d.expiresAt).slice(0, 10) + '.';
3085
+ shareOut.appendChild(exp);
3086
+ }
3087
+ shareBtn.disabled = false;
3088
+ }).catch(function(err){ shareFail(err.message); });
3089
+ });
3090
+ }
3091
+
2751
3092
  var importForm = document.getElementById('import-form');
2752
3093
  if (importForm) {
2753
3094
  importForm.addEventListener('submit', function(e){
@@ -2813,7 +3154,6 @@ export function renderReceiptHtml(model) {
2813
3154
  postureSectionHtml(model.posture, !!model.liveToken),
2814
3155
  capabilitiesPanel(model),
2815
3156
  gatePanel(model.gate, nowMs),
2816
- wiredHostsPanel(model.wiredHosts),
2817
3157
  sandboxesPanel(model.sandboxes, nowMs),
2818
3158
  openshellPanel(model.openshell, nowMs),
2819
3159
  alertsPanel(model.alerts, nowMs, livePaged),
@@ -2825,7 +3165,7 @@ export function renderReceiptHtml(model) {
2825
3165
  <head>
2826
3166
  <meta charset="utf-8"/>
2827
3167
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
2828
- <title>${esc(model.title)} - ${esc(model.rangeLabel)}</title>
3168
+ <title>${esc(PROMISE)} - ${esc(model.rangeLabel)}</title>
2829
3169
  <style>${receiptCss()}${model.liveToken ? MAP_CSS + MAP_LIVE_ZONE_CSS : ""}</style>
2830
3170
  </head>
2831
3171
  <body>
@@ -2837,20 +3177,25 @@ export function renderReceiptHtml(model) {
2837
3177
  </div>
2838
3178
  </header>
2839
3179
  <nav class="sidebar-nav" aria-label="Report sections">
2840
- ${navItem("#overview", "Overview", events.length)}
3180
+ ${navItem("#overview", "Blocked", blockedCalls(events).length)}
2841
3181
  ${navItem("#activity", "Activity", events.length)}
2842
- ${navItem("#changes", "Changes", changes.fileCount)}
2843
- ${navItem("#certify", "Certify", model.certify?.gap ?? 0)}
2844
- ${navItem("#orr", "ORR", model.orr ? model.orr.scorecards.fail : undefined)}
2845
- ${model.liveToken ? navItem("#map", "Map", undefined) : ""}
2846
- ${navGroup("Gateway", model.gate?.state === "running")}
2847
- ${navSubItem("#gateway-home", "Home", model.gate?.servers.length ?? 0)}
2848
- ${navSubItem("#gateway-listeners", "Listeners", model.gate?.listeners?.length ?? 0)}
2849
- ${navSubItem("#gateway-routes", "Routes", model.gate?.routes?.length ?? 0)}
2850
- ${navSubItem("#gateway-backends", "Backends", model.gate?.servers.length ?? 0)}
2851
- ${navSubItem("#gateway-policies", "Policies", model.gate?.policySummary?.totalPolicies ?? 0)}
2852
- ${navSubItem("#gateway-playground", "Playground", model.gate?.playgroundSamples?.length ?? 0)}
2853
- ${navItem("#system", "System", undefined)}
3182
+ ${navItem("#connected", "Connected tools", undefined)}
3183
+ <details class="nav-more" id="nav-more">
3184
+ <summary class="nav-more-summary">More</summary>
3185
+ ${navItem("#changes", "Changes", changes.fileCount)}
3186
+ ${navItem("#certify", "Certify", undefined)}
3187
+ ${navItem("#orr", "ORR", model.orr ? model.orr.scorecards.fail : undefined)}
3188
+ ${model.liveToken ? navItem("#map", "Map", undefined) : ""}
3189
+ ${model.showback ? navItem("#showback", "Showback", undefined) : ""}
3190
+ ${navGroup("Gateway", model.gate?.state === "running")}
3191
+ ${navSubItem("#gateway-home", "Home", model.gate?.servers.length ?? 0)}
3192
+ ${navSubItem("#gateway-listeners", "Listeners", model.gate?.listeners?.length ?? 0)}
3193
+ ${navSubItem("#gateway-routes", "Routes", model.gate?.routes?.length ?? 0)}
3194
+ ${navSubItem("#gateway-backends", "Backends", model.gate?.servers.length ?? 0)}
3195
+ ${navSubItem("#gateway-policies", "Policies", model.gate?.policySummary?.totalPolicies ?? 0)}
3196
+ ${navSubItem("#gateway-playground", "Playground", model.gate?.playgroundSamples?.length ?? 0)}
3197
+ ${navItem("#system", "System", undefined)}
3198
+ </details>
2854
3199
  </nav>
2855
3200
  <footer class="sidebar-footer">
2856
3201
  <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">Theme</button>
@@ -2862,16 +3207,17 @@ export function renderReceiptHtml(model) {
2862
3207
  ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
2863
3208
  <header class="page-header">
2864
3209
  <div class="title-row">
2865
- <h1>${esc(model.title)}</h1>
3210
+ <h1>${esc(PROMISE)}</h1>
2866
3211
  <div class="range">${esc(model.rangeLabel)}${livePill}</div>
2867
3212
  </div>
2868
- ${identityLine(model.identity)}
3213
+ ${identityLine(model.identity, model.planeConfigured)}
3214
+ ${model.liveToken ? shareHeaderBlock() : ""}
2869
3215
  </header>
2870
3216
  ${blockedBanner}
2871
3217
  ${iocBanner}
2872
3218
  <section class="zone" id="overview" aria-labelledby="tab-overview">
2873
3219
  <div class="hero">
2874
- ${fr.fragments.heroCertify}
3220
+ ${fr.fragments.heroBlocked}
2875
3221
  <div class="hero-tiles" id="frag-hero-tiles">
2876
3222
  ${fr.fragments.heroTiles}
2877
3223
  </div>
@@ -2897,6 +3243,9 @@ ${initialFeed.feedHtml}
2897
3243
  ${initialFeed.paginationHtml}
2898
3244
  </div>
2899
3245
  </section>
3246
+ <section class="zone" id="connected" aria-labelledby="tab-connected">
3247
+ <div id="frag-connected">${fr.fragments.connectedBody}</div>
3248
+ </section>
2900
3249
  <section class="zone" id="changes" aria-labelledby="tab-changes">
2901
3250
  <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
2902
3251
  ${fr.fragments.changesList}
@@ -2909,6 +3258,7 @@ ${fr.fragments.changesList}
2909
3258
  <div id="frag-orr">${fr.fragments.orrPanel}</div>
2910
3259
  </section>
2911
3260
  ${model.liveToken ? '<section class="zone" id="map" aria-labelledby="tab-map"><div id="frag-map"><p class="mute">Loading the map.</p></div></section>' : ""}
3261
+ ${model.showback ? `<section class="zone" id="showback" aria-labelledby="tab-showback"><div id="frag-showback">${fr.fragments.showbackPanel}</div></section>` : ""}
2912
3262
  ${gatePagePanel(model.gate, nowMs)}
2913
3263
  <section class="zone" id="system" aria-labelledby="tab-system">
2914
3264
  ${systemBody}
@@ -2918,6 +3268,7 @@ ${filterScript}
2918
3268
  ${model.liveToken ? liveMapScripts() : ""}
2919
3269
  ${staticSearchScript}
2920
3270
  ${themeScript}
3271
+ ${moreScript}
2921
3272
  ${gateScript}
2922
3273
  </body>
2923
3274
  </html>`;
@@ -2925,28 +3276,83 @@ ${gateScript}
2925
3276
  assertNoSecrets(html);
2926
3277
  return html;
2927
3278
  }
3279
+ /** "## Blocked" section: the count and the last five blocked calls, or the demo when none was blocked. */
3280
+ function blockedMarkdown(events, nowMs) {
3281
+ const blocked = blockedCalls(events);
3282
+ if (blocked.length === 0) {
3283
+ return [
3284
+ "## Blocked (demo)",
3285
+ "",
3286
+ "0 calls blocked in this window. Demo: three calls your agent could make, and the guard stops all three. These rows are samples, not your activity.",
3287
+ "",
3288
+ ...DEMO_CALLS.map((c) => `- \`${mdInline(c.label)}\` - ${mdText(c.what)} (demo)`),
3289
+ "",
3290
+ ];
3291
+ }
3292
+ return [
3293
+ "## Blocked",
3294
+ "",
3295
+ `${blocked.length} ${blocked.length === 1 ? "call" : "calls"} blocked in this window. Last ${Math.min(5, blocked.length)}:`,
3296
+ "",
3297
+ ...blocked.slice(0, 5).map((e) => {
3298
+ const sum = e.summary?.trim() ? ` - ${mdText(clip(e.summary.trim(), 140))}` : "";
3299
+ return `- \`${mdInline(e.toolId)}\`${sum} - ${mdText(relativeTime(e.ts, nowMs))}`;
3300
+ }),
3301
+ "",
3302
+ ];
3303
+ }
2928
3304
  export function renderReceiptMarkdown(model) {
2929
3305
  const c = countVerdicts(model.events);
2930
3306
  const agg = aggregateEvents(model.events);
2931
3307
  const nowMs = Date.now();
3308
+ const o = c.outcomes;
2932
3309
  const lines = [
2933
- `# ${mdText(model.title)}`,
3310
+ `# ${mdText(PROMISE)}`,
2934
3311
  "",
2935
3312
  mdText(model.rangeLabel),
2936
3313
  "",
2937
- `Allow ${c.allow} | Deny ${c.deny} | Review ${c.require} | Never ${c.never}`,
3314
+ ...blockedMarkdown(model.events, nowMs),
3315
+ ];
3316
+ if (o.recorded > 0) {
3317
+ // Counted by what happened to each call. Rows from before outcomes existed keep their verdict counts, on a second line.
3318
+ lines.push(`${outcomeHeadline(o)} | Never ${c.never}`, "");
3319
+ if (o.wouldHold > 0)
3320
+ lines.push(strictHowTo(o.wouldHold, (t) => `\`${t}\``), "");
3321
+ if (o.legacy > 0) {
3322
+ lines.push(`Earlier calls (no outcome recorded): Allow ${c.allow} | Deny ${c.deny} | Review ${c.require}`, "");
3323
+ }
3324
+ }
3325
+ else {
3326
+ lines.push(`Allow ${c.allow} | Deny ${c.deny} | Review ${c.require} | Never ${c.never}`, "");
3327
+ }
3328
+ // Feed rows. With outcomes recorded the feed leads (blocked rows first); without, it stays where it was.
3329
+ const feedLines = [
3330
+ "## Feed",
3331
+ ...collapseFeedRows(o.recorded > 0 ? [...model.events].sort(compareFeed) : [...model.events].sort((a, b) => b.ts.localeCompare(a.ts)))
3332
+ .flatMap(({ event: e, count }) => {
3333
+ const text = count > 1 ? repeatText(e, count) : e.summary?.trim();
3334
+ const sum = text ? ` - ${mdText(text)}` : "";
3335
+ const note = ranNote(e);
3336
+ const head = `- **${mdText(rowWord(e))}**${note ? ` (${note})` : ""} \`${mdInline(e.toolId)}\`${sum} - ${productLabel(e.product)} - ${mdText(e.reasonCode)}`;
3337
+ if (!e.diffPreview?.trim())
3338
+ return [head];
3339
+ // Fenced content is verbatim (stripEscapes only - backslash escapes
3340
+ // are literal in code fences); the dynamically sized tilde fence
3341
+ // prevents breakout and backticks cannot close a tilde fence.
3342
+ const diff = stripEscapes(e.diffPreview.trim());
3343
+ const fence = mdFence(diff);
3344
+ return [head, "", fence, diff, fence, ""];
3345
+ }),
2938
3346
  "",
2939
3347
  ];
3348
+ if (o.recorded > 0)
3349
+ lines.push(...feedLines);
2940
3350
  if (model.ioc) {
2941
3351
  const k = model.ioc.findings.length;
2942
3352
  lines.push(mdText(`IoC check (advisory): ${k === 0 ? "no findings" : `${k} finding${k === 1 ? "" : "s"}`}, list ${model.ioc.listVersion}, checked ${model.ioc.checkedAt}.`), "");
2943
3353
  }
2944
3354
  if (model.identity) {
2945
- const id = model.identity;
2946
- const nameId = id.agentName && id.agentId
2947
- ? `${id.agentName}/${id.agentId}`
2948
- : (id.agentName ?? id.agentId);
2949
- const bits = [nameId, id.host, id.baseUrl].filter((b) => typeof b === "string" && b.trim().length > 0);
3355
+ const bits = identityBits(model.identity, model.planeConfigured);
2950
3356
  if (bits.length > 0) {
2951
3357
  lines.push(mdText(bits.join(" · ")), "");
2952
3358
  }
@@ -2961,7 +3367,7 @@ export function renderReceiptMarkdown(model) {
2961
3367
  agg.planes.runtime > 0 ? `Runtime ${agg.planes.runtime}` : "",
2962
3368
  ].filter(Boolean);
2963
3369
  if (modeBits.length > 0 || planeBits.length > 0) {
2964
- lines.push(`Modes: ${modeBits.join(" | ") || "-"} · Planes: ${planeBits.join(" | ") || "-"}`, "");
3370
+ lines.push(`Modes: ${modeBits.join(" | ") || "-"} · Source: ${planeBits.join(" | ") || "-"}`, "");
2965
3371
  }
2966
3372
  if (agg.products.length >= 2) {
2967
3373
  lines.push("## Products", ...agg.products.map((p) => `- ${mdText(p.label)} × ${p.count}`), "");
@@ -2969,23 +3375,16 @@ export function renderReceiptMarkdown(model) {
2969
3375
  if (agg.projects.length >= 2) {
2970
3376
  lines.push("## Projects", ...agg.projects.map((p) => `- ${mdText(p.label)} × ${p.count}`), "");
2971
3377
  }
2972
- if (model.certify) {
2973
- const c = model.certify;
2974
- lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
2975
- if (c.topGaps.length > 0) {
2976
- lines.push("Top gaps:");
2977
- for (const g of c.topGaps) {
2978
- lines.push(`- \`${mdInline(g.id)}\` - ${mdText(g.title)} (${mdText(g.severity)})`);
2979
- }
2980
- }
2981
- lines.push(`checked live - last ${c.windowDays} days, ${c.trailEvents} recorded events · the Certify tab lists every rule · kya certify writes the gap report and signed proof file`, "");
2982
- }
2983
3378
  const db = computeDashboard(model.events);
2984
3379
  if (db.verdictMix.total > 0) {
2985
3380
  const pct = (n) => Math.round((n / db.verdictMix.total) * 100);
2986
3381
  // never is a reason overlay: the Never share is computed from neverEvent
2987
3382
  // rows on top of the same 100% = total events base as the verdict buckets.
2988
- lines.push("## Analytics", "", `Verdict mix: Allow ${pct(db.verdictMix.allow)}% · Review ${pct(db.verdictMix.review)}% · Deny ${pct(db.verdictMix.deny)}% · Never ${pct(db.verdictMix.never)}%`, "");
3383
+ const om = db.outcomeMix;
3384
+ const mixLine = om.recorded > 0
3385
+ ? `Outcome mix: Ran ${pct(om.ran)}% · Blocked ${pct(om.blocked)}% · Held ${pct(om.held)}%${om.error > 0 ? ` · Error ${pct(om.error)}%` : ""}${om.legacy > 0 ? ` · Earlier (no outcome): Allow ${pct(om.legacyMix.allow)}% · Review ${pct(om.legacyMix.review)}% · Deny ${pct(om.legacyMix.deny)}%` : ""} · Never ${pct(db.verdictMix.never)}%`
3386
+ : `Verdict mix: Allow ${pct(db.verdictMix.allow)}% · Review ${pct(db.verdictMix.review)}% · Deny ${pct(db.verdictMix.deny)}% · Never ${pct(db.verdictMix.never)}%`;
3387
+ lines.push("## Analytics", "", mixLine, "");
2989
3388
  if (db.activity.buckets.length > 0) {
2990
3389
  const max = Math.max(1, ...db.activity.buckets.map((b) => b.count));
2991
3390
  lines.push(`Activity (${db.activity.granularity === "hour" ? "hourly" : "daily"}):`);
@@ -3013,20 +3412,8 @@ export function renderReceiptMarkdown(model) {
3013
3412
  lines.push("");
3014
3413
  }
3015
3414
  }
3016
- lines.push("## Feed", ...[...model.events]
3017
- .sort((a, b) => b.ts.localeCompare(a.ts))
3018
- .flatMap((e) => {
3019
- const sum = e.summary?.trim() ? ` - ${mdText(e.summary.trim())}` : "";
3020
- const head = `- **${mdText(verdictWord(e.verdict))}** \`${mdInline(e.toolId)}\`${sum} - ${productLabel(e.product)} - ${mdText(e.reasonCode)}`;
3021
- if (!e.diffPreview?.trim())
3022
- return [head];
3023
- // Fenced content is verbatim (stripEscapes only - backslash escapes
3024
- // are literal in code fences); the dynamically sized tilde fence
3025
- // prevents breakout and backticks cannot close a tilde fence.
3026
- const diff = stripEscapes(e.diffPreview.trim());
3027
- const fence = mdFence(diff);
3028
- return [head, "", fence, diff, fence, ""];
3029
- }), "");
3415
+ if (o.recorded === 0)
3416
+ lines.push(...feedLines);
3030
3417
  // Same normalization as the HTML path: targetPath flows into single-line
3031
3418
  // `#### ` code-span headers, so embedded newlines must be flattened first.
3032
3419
  const changes = buildChangesModel(normalizeTrailEvents(model.events));
@@ -3041,7 +3428,7 @@ export function renderReceiptMarkdown(model) {
3041
3428
  // fence one longer than the longest run inside.
3042
3429
  const diff = stripEscapes(e.preview);
3043
3430
  const fence = mdFence(diff);
3044
- lines.push(`- **${mdText(verdictWord(e.verdict))}** \`${mdInline(e.toolId)}\` - ${mdText(e.ts)}`, "", fence, diff, fence, "");
3431
+ lines.push(`- **${mdText(rowWord(e))}** \`${mdInline(e.toolId)}\` - ${mdText(e.ts)}`, "", fence, diff, fence, "");
3045
3432
  }
3046
3433
  }
3047
3434
  }
@@ -3147,6 +3534,18 @@ export function renderReceiptMarkdown(model) {
3147
3534
  const ot = model.otel;
3148
3535
  lines.push("## Export to your monitoring (OTel)", `${ot.endpoint ? `\`${mdInline(ot.endpoint)}\` · ` : ""}${ot.spansSent} sent · ${ot.spansFailed} failed${ot.lastExportAt ? ` · last export ${relativeTime(ot.lastExportAt, nowMs)}` : ""}`, "");
3149
3536
  }
3537
+ // Certify last: no gap count reaches the top of the report.
3538
+ if (model.certify) {
3539
+ const c = model.certify;
3540
+ lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
3541
+ if (c.topGaps.length > 0) {
3542
+ lines.push("Top gaps:");
3543
+ for (const g of c.topGaps) {
3544
+ lines.push(`- \`${mdInline(g.id)}\` - ${mdText(g.title)} (${mdText(g.severity)})`);
3545
+ }
3546
+ }
3547
+ lines.push(`checked live - last ${c.windowDays} days, ${c.trailEvents} recorded events · the Certify tab lists every rule · kya certify writes the gap report and signed proof file`, "");
3548
+ }
3150
3549
  const md = `${lines.join("\n")}\n`;
3151
3550
  assertNoSecrets(md);
3152
3551
  return md;