@iris-eval/mcp-server 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +99 -36
  2. package/dist/config/index.d.ts +10 -0
  3. package/dist/config/index.js +33 -7
  4. package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
  5. package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
  6. package/dist/dashboard/index.html +4 -3
  7. package/dist/dashboard/routes/health.js +10 -3
  8. package/dist/dashboard/routes/moments.js +1 -1
  9. package/dist/dashboard/routes/preferences.d.ts +1 -0
  10. package/dist/dashboard/routes/preferences.js +31 -3
  11. package/dist/dashboard/routes/rules.d.ts +18 -0
  12. package/dist/dashboard/routes/rules.js +160 -6
  13. package/dist/dashboard/routes/traces.js +30 -3
  14. package/dist/dashboard/seed-demo-data.js +14 -3
  15. package/dist/dashboard/server.js +13 -3
  16. package/dist/dashboard/session-auth.d.ts +8 -0
  17. package/dist/dashboard/session-auth.js +237 -0
  18. package/dist/dashboard/validation.d.ts +9 -3
  19. package/dist/dashboard/validation.js +69 -11
  20. package/dist/eval/citation-verify/verifier.d.ts +17 -0
  21. package/dist/eval/citation-verify/verifier.js +68 -15
  22. package/dist/eval/decision-moment.js +17 -9
  23. package/dist/eval/engine.d.ts +62 -0
  24. package/dist/eval/engine.js +196 -58
  25. package/dist/eval/llm-judge/evaluator.js +50 -33
  26. package/dist/eval/llm-judge/templates/index.d.ts +4 -0
  27. package/dist/eval/llm-judge/templates/index.js +10 -4
  28. package/dist/eval/rules/custom.js +59 -6
  29. package/dist/eval/rules/relevance.js +1 -1
  30. package/dist/eval/rules/safety.d.ts +8 -0
  31. package/dist/eval/rules/safety.js +63 -18
  32. package/dist/index.js +102 -16
  33. package/dist/middleware/rate-limit.d.ts +25 -0
  34. package/dist/middleware/rate-limit.js +54 -2
  35. package/dist/self-test.d.ts +14 -0
  36. package/dist/self-test.js +97 -13
  37. package/dist/storage/demo-guard.d.ts +8 -0
  38. package/dist/storage/demo-guard.js +53 -0
  39. package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
  40. package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
  41. package/dist/storage/migrations/index.js +2 -0
  42. package/dist/storage/sqlite-adapter.d.ts +6 -0
  43. package/dist/storage/sqlite-adapter.js +91 -4
  44. package/dist/tools/delete-rule.js +49 -11
  45. package/dist/tools/deploy-rule.d.ts +33 -0
  46. package/dist/tools/deploy-rule.js +130 -27
  47. package/dist/tools/evaluate-output.js +50 -24
  48. package/dist/tools/evaluate-with-llm-judge.js +11 -4
  49. package/dist/tools/get-traces.d.ts +27 -0
  50. package/dist/tools/get-traces.js +60 -8
  51. package/dist/tools/list-rules.js +2 -2
  52. package/dist/tools/log-trace.js +5 -4
  53. package/dist/tools/strict-input.d.ts +1 -0
  54. package/dist/tools/strict-input.js +25 -0
  55. package/dist/tools/trace-link.d.ts +7 -0
  56. package/dist/tools/trace-link.js +39 -0
  57. package/dist/tools/verify-citations.d.ts +19 -0
  58. package/dist/tools/verify-citations.js +42 -5
  59. package/dist/types/decision-moment.d.ts +8 -0
  60. package/dist/types/eval.d.ts +60 -1
  61. package/dist/types/index.d.ts +1 -1
  62. package/dist/types/query.d.ts +25 -0
  63. package/package.json +1 -1
  64. package/server.json +2 -2
  65. package/dist/dashboard/assets/index-BZZt8bVh.js +0 -10
@@ -1 +1 @@
1
- @font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-cyrillic-ext.woff2)format("woff2");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-cyrillic.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-greek.woff2)format("woff2");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-vietnamese.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-cyrillic-ext.woff2)format("woff2");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-cyrillic.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-greek.woff2)format("woff2");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-vietnamese.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Space Grotesk;font-style:normal;font-weight:500 700;font-display:swap;src:url(/fonts/space-grotesk-vietnamese.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Space Grotesk;font-style:normal;font-weight:500 700;font-display:swap;src:url(/fonts/space-grotesk-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Space Grotesk;font-style:normal;font-weight:500 700;font-display:swap;src:url(/fonts/space-grotesk-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--iris-50:#f0fdfa;--iris-100:#ccfbf1;--iris-200:#99f6e4;--iris-300:#5eead4;--iris-400:#2dd4bf;--iris-500:#14b8a6;--iris-600:#0d9488;--iris-700:#0f766e;--iris-800:#115e59;--iris-900:#134e4a;--iris-950:#042f2e;--eval-pass:#22c55e;--eval-warn:#eab308;--eval-fail:#ef4444;--eval-tool:#3b82f6;--eval-llm:#a855f7;--eval-skipped:#71717a}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=dark]{--bg-base:#050508;--bg-raised:#08080e;--bg-surface:#0d0d15;--bg-card:#101018;--bg-card-hover:#16161f;--border-subtle:#ffffff0d;--border-default:#ffffff14;--border-strong:#ffffff24;--border-glow:#14b8a680;--text-primary:#f0f0f5;--text-secondary:#9494a8;--text-muted:#5e5e72;--text-accent:var(--iris-400);--glow-primary:#14b8a61f;--glow-strong:#14b8a640;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080}[data-theme=light]{--bg-base:#fafcfc;--bg-raised:#f1f5f5;--bg-surface:#e8eded;--bg-card:#fff;--bg-card-hover:#f4f8f8;--border-subtle:#0000000a;--border-default:#00000014;--border-strong:#00000024;--border-glow:#0d948859;--text-primary:#0a0f0e;--text-secondary:#3d5250;--text-muted:#7a908e;--text-accent:var(--iris-700);--glow-primary:#0d94880f;--glow-strong:#0d94881f;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 6px #00000014;--shadow-lg:0 10px 15px #0000001a}:root{--font-display:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-body:"Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, monospace;--font-sans:var(--font-body);--text-caption-xs:11px;--text-caption:12px;--text-body-sm:13px;--text-body:14px;--text-body-lg:15px;--text-heading-sm:16px;--text-heading:20px;--text-display-sm:28px;--text-display:40px;--font-size-xs:var(--text-caption);--font-size-sm:var(--text-body-sm);--font-size-base:var(--text-body);--font-size-lg:var(--text-body-lg);--font-size-xl:var(--text-heading-sm);--font-size-2xl:var(--text-heading);--font-size-3xl:var(--text-display-sm);--leading-body:1.5;--leading-heading:1.2;--leading-display:1.1;--leading-mono:1.4;--space-0_5:2px;--space-1:4px;--space-1_5:6px;--space-2:8px;--space-2_5:10px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--space-8:32px;--space-10:40px;--space-12:48px;--space-16:64px;--space-20:80px;--space-24:96px}:root,[data-density=compact]{--density-row:32px;--density-padding:var(--space-3);--density-body:var(--text-body-sm)}[data-density=comfortable]{--density-row:44px;--density-padding:var(--space-4);--density-body:var(--text-body)}:root{--sidebar-width-expanded:256px;--sidebar-width-collapsed:64px;--header-height:56px;--page-toolbar-height:40px;--radius-xs:4px;--radius-sm:6px;--radius:8px;--radius-lg:12px;--radius-xl:16px;--radius-pill:999px;--border-radius:var(--radius);--border-radius-sm:var(--radius-xs);--border-radius-lg:var(--radius-lg);--transition-instant:.1s ease;--transition-fast:.15s ease;--transition-base:.2s ease;--transition-slow:.3s ease;--ease-iris:cubic-bezier(.25, .4, .25, 1);--bg-primary:var(--bg-base);--bg-secondary:var(--bg-raised);--bg-tertiary:var(--bg-surface);--bg-hover:var(--bg-card-hover);--border-color:var(--border-default);--accent-primary:var(--iris-500);--accent-primary-hover:var(--iris-400);--accent-success:var(--eval-pass);--accent-error:var(--eval-fail);--accent-warning:var(--eval-warn);--accent-tool:var(--eval-tool);--accent-llm:var(--eval-llm)}html{transition:background-color var(--transition-base), color var(--transition-base)}.iris-btn{appearance:none;justify-content:center;align-items:center;gap:var(--space-1_5);border-radius:var(--radius-sm);font-family:inherit;font-size:var(--text-body-sm);font-weight:500;line-height:var(--leading-body);padding:var(--space-1_5) var(--space-3);cursor:pointer;-webkit-user-select:none;user-select:none;transition:background-color var(--transition-instant), border-color var(--transition-instant), color var(--transition-instant), transform var(--transition-instant);border:1px solid #0000;text-decoration:none;display:inline-flex}.iris-btn:disabled,.iris-btn[aria-busy=true]{opacity:.5;cursor:default}.iris-btn:not(:disabled):active{transform:translateY(1px)}.iris-btn--ghost{border-color:var(--border-default);color:var(--text-secondary);background:0 0}.iris-btn--ghost:not(:disabled):hover{background:var(--bg-card-hover);border-color:var(--border-strong);color:var(--text-primary)}.iris-btn--primary{background:var(--iris-600);border-color:var(--iris-600);color:#fff;font-weight:600}.iris-btn--primary:not(:disabled):hover{background:var(--iris-500);border-color:var(--iris-500)}.iris-btn--danger{border-color:var(--border-default);color:var(--eval-fail);background:0 0}.iris-btn--danger:not(:disabled):hover{border-color:var(--eval-fail);background:color-mix(in srgb, var(--eval-fail) 10%, transparent)}.iris-btn--danger-solid{background:var(--eval-fail);border-color:var(--eval-fail);color:#fff;font-weight:600}.iris-btn--danger-solid:not(:disabled):hover{background:#dc2626;border-color:#dc2626}.iris-btn--sm{padding:var(--space-0_5) var(--space-2_5);font-size:var(--text-caption)}.iris-btn--mono{font-family:var(--font-mono)}.iris-kbd{font-size:var(--text-caption);font-family:var(--font-mono);background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xs);padding:2px var(--space-2);color:var(--text-muted)}.iris-card{background:var(--bg-card);border:1px solid var(--border-default);border-radius:var(--radius-lg);transition:background-color var(--transition-fast), border-color var(--transition-fast)}.iris-card--hover:hover,.iris-card--hover:focus-within{border-color:var(--border-strong)}.iris-stack{gap:var(--space-3);flex-direction:column;display:flex}.iris-stack--lg{gap:var(--space-6)}.iris-row{align-items:center;gap:var(--space-3);display:flex}.iris-grid-kpis{gap:var(--space-3);grid-template-columns:repeat(auto-fit,minmax(180px,1fr));display:grid}.iris-grid-split{gap:var(--space-3);grid-template-columns:repeat(auto-fit,minmax(340px,1fr));display:grid}.iris-grid-gauge{gap:var(--space-3);grid-template-columns:minmax(280px,1fr) minmax(0,1.8fr);align-items:stretch;display:grid}@media (width<=960px){.iris-grid-gauge{grid-template-columns:1fr}}.iris-num{font-variant-numeric:tabular-nums}.iris-num--right{text-align:right}.iris-error-box{background:color-mix(in srgb, var(--eval-fail) 12%, transparent);border:1px solid var(--eval-fail);border-radius:var(--radius);padding:var(--space-4);color:var(--eval-fail);gap:var(--space-2);flex-direction:column;display:flex}.iris-backdrop{z-index:110;background:oklch(0% 0 0/.55);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.iris-modal{background:var(--bg-card);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);flex-direction:column;display:flex;overflow:hidden}.moment-card{gap:var(--space-3);padding:var(--space-4);color:var(--text-primary);border-left:3px solid var(--moment-sig-color,transparent);grid-template-columns:24px 48px 1fr auto;align-items:start;display:grid}.moment-card:hover,.moment-card:focus-within{background:var(--bg-card-hover);border-left-color:var(--moment-sig-color,transparent)}.moment-card--archived{opacity:.5}.moment-card--selected{background:var(--bg-card-hover);border-color:var(--iris-500);border-left-color:var(--moment-sig-color,transparent)}.moment-card__checkbox-wrap{padding-top:var(--space-1);justify-content:center;align-items:center;display:flex}.moment-card__checkbox{cursor:pointer;width:16px;height:16px;accent-color:var(--iris-500)}.moment-card__rail{align-items:center;gap:var(--space-1);flex-direction:column;display:flex}.moment-card__glyph{width:32px;height:32px;font-size:var(--text-body);font-family:var(--font-mono);color:var(--bg-base);border-radius:50%;justify-content:center;align-items:center;font-weight:700;display:flex}.moment-card__body-link{color:inherit;gap:var(--space-2);flex-direction:column;min-width:0;text-decoration:none;display:flex}.moment-card__body-link:hover{color:inherit}.moment-card__header{align-items:center;gap:var(--space-2);flex-wrap:wrap;display:flex}.moment-card__agent{font-size:var(--text-body-sm);color:var(--text-primary);font-weight:600}.moment-card__sig{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-secondary);text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.moment-card__verdict{font-size:var(--text-caption);font-family:var(--font-mono);letter-spacing:.05em;font-weight:700}.moment-card__tag{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);background:var(--bg-surface);border:1px solid var(--border-default);padding:0 var(--space-2);border-radius:var(--radius-xs)}.moment-card__tag--new{letter-spacing:.05em;color:var(--text-accent);background:var(--glow-primary);border-color:var(--iris-500);font-weight:700}.moment-card__preview{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.moment-card__chips{gap:var(--space-1);flex-wrap:wrap;display:flex}.moment-card__chip{font-size:var(--text-caption);font-family:var(--font-mono);padding:1px var(--space-1_5);border-radius:var(--radius-xs);background:color-mix(in srgb, var(--eval-fail) 12%, transparent);color:var(--eval-fail)}.moment-card__meta{align-items:flex-end;gap:var(--space-1);font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);flex-direction:column;display:flex}.view-tabs{align-items:center;gap:var(--space-1);border-bottom:1px solid var(--border-subtle);margin-bottom:var(--space-5);display:flex}.view-tabs__tab{align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);font-family:var(--font-display);font-size:var(--text-body-sm);letter-spacing:-.01em;color:var(--text-muted);cursor:pointer;transition:color var(--transition-fast), border-color var(--transition-fast);border-bottom:2px solid #0000;margin-bottom:-1px;font-weight:600;text-decoration:none;display:inline-flex}.view-tabs__tab:hover{color:var(--text-primary)}.view-tabs__tab[aria-selected=true]{color:var(--text-primary);border-bottom-color:var(--iris-500)}.view-tabs__hint{font-size:var(--text-caption-xs);color:var(--text-muted);font-family:var(--font-mono);padding-left:var(--space-3);margin-left:auto}.cmdk-backdrop{padding-top:15vh}.cmdk__panel{width:min(640px,100% - 32px);max-height:70vh}.cmdk__input-wrap{padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border-default);align-items:center;gap:var(--space-2);display:flex}.cmdk__prompt{font-family:var(--font-mono);color:var(--text-muted);font-size:var(--text-body-sm)}.cmdk__input{color:var(--text-primary);font-size:var(--text-body);background:0 0;border:none;outline:none;flex:1;font-family:inherit}.cmdk__list{padding:var(--space-2);flex:1;overflow:auto}.cmdk__section-title{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;padding:var(--space-2) var(--space-3)}.cmdk__item{justify-content:space-between;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);cursor:pointer;color:var(--text-secondary);display:flex}.cmdk__item:hover,.cmdk__item[aria-selected=true]{background:var(--bg-card-hover);color:var(--text-primary)}.cmdk__item-body{flex-direction:column;gap:2px;min-width:0;display:flex}.cmdk__item-title{font-size:var(--text-body-sm);font-weight:500}.cmdk__item-subtitle{font-size:var(--text-caption);color:var(--text-muted);font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.cmdk__empty{padding:var(--space-6);text-align:center;color:var(--text-muted);font-size:var(--text-body-sm)}.cmdk__empty-clear{color:var(--text-accent);cursor:pointer;font-size:inherit;background:0 0;border:none;font-family:inherit;text-decoration:underline}.cmdk__footer{gap:var(--space-3);font-size:var(--text-caption);color:var(--text-muted);font-family:var(--font-mono);padding:var(--space-2) var(--space-4);border-top:1px solid var(--border-default);background:var(--bg-surface);display:flex}.cmdk__footer-count{margin-left:auto}.confirm-dialog__panel{width:min(440px,100% - 32px);padding:var(--space-5);gap:var(--space-3);margin-top:20vh}.confirm-dialog__title{font-family:var(--font-display);font-size:var(--text-heading-sm);letter-spacing:-.01em;color:var(--text-primary);margin:0;font-weight:600}.confirm-dialog__body{font-size:var(--text-body-sm);color:var(--text-secondary);line-height:var(--leading-body);margin:0}.confirm-dialog__error{font-size:var(--text-body-sm);color:var(--eval-fail);margin:0}.confirm-dialog__actions{justify-content:flex-end;gap:var(--space-2);margin-top:var(--space-2);display:flex}.stat-tile{padding:var(--space-4) var(--space-5);gap:var(--space-2);flex-direction:column;min-height:110px;display:flex}.stat-tile__header{align-items:center;gap:var(--space-2);font-size:var(--text-caption-xs);font-weight:600;font-family:var(--font-body);text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);display:flex}.stat-tile__value-row{align-items:baseline;gap:var(--space-2);display:flex}.stat-tile__value{font-family:var(--font-display);font-size:var(--text-display-sm);font-weight:700;line-height:var(--leading-display);letter-spacing:-.02em;font-variant-numeric:tabular-nums}.stat-tile__delta{font-family:var(--font-mono);font-size:var(--text-caption);font-weight:500}.stat-tile__sub{font-size:var(--text-caption);color:var(--text-secondary)}.section-header{padding-top:var(--space-3);margin-bottom:var(--space-1);flex-direction:column;gap:2px;display:flex}.section-header__top{justify-content:space-between;align-items:baseline;gap:var(--space-3);flex-wrap:wrap;display:flex}.section-header__title{font-family:var(--font-display);font-size:var(--text-heading-sm);letter-spacing:-.015em;color:var(--text-primary);margin:0;font-weight:600}.section-header__trailing{font-family:var(--font-mono);font-size:var(--text-caption);color:var(--text-muted)}.section-header__question{font-size:var(--text-caption);color:var(--text-muted);margin:0}.detail-back{color:var(--text-muted);font-size:var(--text-body-sm);align-items:center;gap:var(--space-1);width:fit-content;transition:color var(--transition-instant);text-decoration:none;display:inline-flex}.detail-back:hover{color:var(--text-primary)}.detail-card{padding:var(--space-5)}.detail-card__grid{gap:var(--space-4);font-size:var(--text-body-sm);grid-template-columns:repeat(auto-fit,minmax(200px,1fr));display:grid}.detail-card__label{color:var(--text-muted);font-size:var(--text-caption);font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.05em}.detail-section{gap:var(--space-3);flex-direction:column;display:flex}.detail-section__title{font-family:var(--font-display);font-size:var(--text-heading-sm);color:var(--text-primary);letter-spacing:-.01em;font-weight:600;line-height:var(--leading-heading);margin:0}.eval-card{padding:var(--space-4);gap:var(--space-3);flex-direction:column;display:flex}.eval-card__badges{align-items:center;gap:var(--space-3);display:flex}.eval-card__rules{gap:var(--space-2);flex-direction:column;display:flex}.eval-card__rule{align-items:center;gap:var(--space-2);font-size:var(--text-body-sm);padding:var(--space-1) var(--space-2);background:var(--bg-base);border-radius:var(--radius-xs);display:flex}.eval-card__rule-mark{width:16px}.eval-card__rule-name{color:var(--text-secondary);font-size:var(--text-caption)}.eval-card__rule-message{color:var(--text-muted);font-size:var(--text-caption);flex:1}.eval-card__suggestions{font-size:var(--text-body-sm)}.eval-card__suggestions-label{color:var(--text-muted);margin-bottom:var(--space-1)}.eval-card__suggestions-list{padding-left:var(--space-5);color:var(--text-secondary);margin:0}.rule-card{padding:var(--space-4);gap:var(--space-3);grid-template-columns:1fr auto;align-items:start;display:grid}.rule-card__body{gap:var(--space-2);flex-direction:column;min-width:0;display:flex}.rule-card__name-row{gap:var(--space-2);flex-wrap:wrap;align-items:baseline;display:flex}.rule-card__name{font-size:var(--text-body);font-weight:600;font-family:var(--font-mono);color:var(--text-primary)}.rule-card__description{font-size:var(--text-body-sm);color:var(--text-secondary);margin:0}.rule-card__meta{gap:var(--space-3);font-size:var(--text-caption);color:var(--text-muted);font-family:var(--font-mono);flex-wrap:wrap;display:flex}.rule-card__badge{font-size:var(--text-caption);font-family:var(--font-mono);padding:1px var(--space-1_5);border-radius:var(--radius-xs);background:var(--bg-surface);color:var(--text-secondary)}.rule-card__source-link{color:var(--text-accent);font-size:var(--text-caption);font-family:var(--font-mono);text-decoration:underline}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-body);font-size:var(--text-body);color:var(--text-primary);background-color:var(--bg-base);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;font-feature-settings:"cv11", "ss01";font-variant-numeric:tabular-nums}h1,h2,h3,h4,.display{font-family:var(--font-display);letter-spacing:-.01em;font-weight:600}a{color:var(--text-accent);text-decoration:none}a:hover{color:var(--iris-300)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--iris-500);outline-offset:2px;border-radius:var(--radius-xs)}.iris-sr-reveal{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.iris-sr-reveal:focus-within{width:auto;height:auto;margin:var(--space-3) 0 0 0;padding:var(--space-3) var(--space-4);clip:auto;white-space:normal;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--iris-500);border-radius:var(--radius-sm);position:static;overflow:visible}.iris-sr-reveal:focus-within>li{padding:var(--space-1) 0;list-style:none}::selection{background:var(--iris-600);color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-base)}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:var(--radius-xs)}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}html{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}@keyframes pulse-ring{0%{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(2.5)}}.pulse-dot{position:relative}.pulse-dot:after{content:"";background:var(--iris-500);border-radius:50%;animation:2s ease-out infinite pulse-ring;position:absolute;inset:-2px}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (width<=767px){aside[aria-label=Main\ navigation]{width:160px}main{overflow-x:auto}}@media print{body{color:#000!important;background:#fff!important}aside[aria-label=Main\ navigation],header,[role=region][aria-label=Welcome],[role=region][aria-label=Bulk\ actions],[role=status],[role=dialog]{display:none!important}body,#root,main{height:auto!important;overflow:visible!important}main{padding:0!important}tr,pre,code{page-break-inside:avoid}h1,h2,h3{page-break-after:avoid}[aria-label*=violation],[aria-label*=spike],[aria-label*=collision],[aria-label*=Pass],[aria-label*=Fail]{border:1px solid #000!important}a{color:#000!important;text-decoration:underline!important}a[href^=http]:after{content:" (" attr(href) ")";color:#555;font-size:80%}}
1
+ @font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-cyrillic-ext.woff2)format("woff2");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-cyrillic.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-greek.woff2)format("woff2");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-vietnamese.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/jetbrains-mono-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-cyrillic-ext.woff2)format("woff2");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-cyrillic.woff2)format("woff2");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-greek.woff2)format("woff2");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-vietnamese.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/manrope-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Space Grotesk;font-style:normal;font-weight:500 700;font-display:swap;src:url(/fonts/space-grotesk-vietnamese.woff2)format("woff2");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Space Grotesk;font-style:normal;font-weight:500 700;font-display:swap;src:url(/fonts/space-grotesk-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Space Grotesk;font-style:normal;font-weight:500 700;font-display:swap;src:url(/fonts/space-grotesk-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--iris-50:#f0fdfa;--iris-100:#ccfbf1;--iris-200:#99f6e4;--iris-300:#5eead4;--iris-400:#2dd4bf;--iris-500:#14b8a6;--iris-600:#0d9488;--iris-700:#0f766e;--iris-800:#115e59;--iris-900:#134e4a;--iris-950:#042f2e;--eval-pass:#22c55e;--eval-warn:#eab308;--eval-fail:#ef4444;--eval-tool:#3b82f6;--eval-llm:#a855f7;--eval-skipped:#71717a}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=dark]{--bg-base:#050508;--bg-raised:#08080e;--bg-surface:#0d0d15;--bg-card:#101018;--bg-card-hover:#16161f;--border-subtle:#ffffff0d;--border-default:#ffffff14;--border-strong:#ffffff24;--border-glow:#14b8a680;--text-primary:#f0f0f5;--text-secondary:#9494a8;--text-muted:#5e5e72;--text-accent:var(--iris-400);--glow-primary:#14b8a61f;--glow-strong:#14b8a640;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080}[data-theme=light]{--bg-base:#fafcfc;--bg-raised:#f1f5f5;--bg-surface:#e8eded;--bg-card:#fff;--bg-card-hover:#f4f8f8;--border-subtle:#0000000a;--border-default:#00000014;--border-strong:#00000024;--border-glow:#0d948859;--text-primary:#0a0f0e;--text-secondary:#3d5250;--text-muted:#7a908e;--text-accent:var(--iris-700);--glow-primary:#0d94880f;--glow-strong:#0d94881f;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 6px #00000014;--shadow-lg:0 10px 15px #0000001a}:root{--font-display:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-body:"Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, monospace;--font-sans:var(--font-body);--text-caption-xs:11px;--text-caption:12px;--text-body-sm:13px;--text-body:14px;--text-body-lg:15px;--text-heading-sm:16px;--text-heading:20px;--text-display-sm:28px;--text-display:40px;--font-size-xs:var(--text-caption);--font-size-sm:var(--text-body-sm);--font-size-base:var(--text-body);--font-size-lg:var(--text-body-lg);--font-size-xl:var(--text-heading-sm);--font-size-2xl:var(--text-heading);--font-size-3xl:var(--text-display-sm);--leading-body:1.5;--leading-heading:1.2;--leading-display:1.1;--leading-mono:1.4;--space-0_5:2px;--space-1:4px;--space-1_5:6px;--space-2:8px;--space-2_5:10px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--space-8:32px;--space-10:40px;--space-12:48px;--space-16:64px;--space-20:80px;--space-24:96px}:root,[data-density=compact]{--density-row:32px;--density-padding:var(--space-3);--density-body:var(--text-body-sm)}[data-density=comfortable]{--density-row:44px;--density-padding:var(--space-4);--density-body:var(--text-body)}:root{--sidebar-width-expanded:256px;--sidebar-width-collapsed:64px;--header-height:56px;--page-toolbar-height:40px;--radius-xs:4px;--radius-sm:6px;--radius:8px;--radius-lg:12px;--radius-xl:16px;--radius-pill:999px;--border-radius:var(--radius);--border-radius-sm:var(--radius-xs);--border-radius-lg:var(--radius-lg);--transition-instant:.1s ease;--transition-fast:.15s ease;--transition-base:.2s ease;--transition-slow:.3s ease;--ease-iris:cubic-bezier(.25, .4, .25, 1);--bg-primary:var(--bg-base);--bg-secondary:var(--bg-raised);--bg-tertiary:var(--bg-surface);--bg-hover:var(--bg-card-hover);--border-color:var(--border-default);--accent-primary:var(--iris-500);--accent-primary-hover:var(--iris-400);--accent-success:var(--eval-pass);--accent-error:var(--eval-fail);--accent-warning:var(--eval-warn);--accent-tool:var(--eval-tool);--accent-llm:var(--eval-llm)}html{transition:background-color var(--transition-base), color var(--transition-base)}.iris-btn{appearance:none;justify-content:center;align-items:center;gap:var(--space-1_5);border-radius:var(--radius-sm);font-family:inherit;font-size:var(--text-body-sm);font-weight:500;line-height:var(--leading-body);padding:var(--space-1_5) var(--space-3);cursor:pointer;-webkit-user-select:none;user-select:none;transition:background-color var(--transition-instant), border-color var(--transition-instant), color var(--transition-instant), transform var(--transition-instant);border:1px solid #0000;text-decoration:none;display:inline-flex}.iris-btn:disabled,.iris-btn[aria-busy=true]{opacity:.5;cursor:default}.iris-btn:not(:disabled):active{transform:translateY(1px)}.iris-btn--ghost{border-color:var(--border-default);color:var(--text-secondary);background:0 0}.iris-btn--ghost:not(:disabled):hover{background:var(--bg-card-hover);border-color:var(--border-strong);color:var(--text-primary)}.iris-btn--primary{background:var(--iris-600);border-color:var(--iris-600);color:#fff;font-weight:600}.iris-btn--primary:not(:disabled):hover{background:var(--iris-500);border-color:var(--iris-500)}.iris-btn--danger{border-color:var(--border-default);color:var(--eval-fail);background:0 0}.iris-btn--danger:not(:disabled):hover{border-color:var(--eval-fail);background:color-mix(in srgb, var(--eval-fail) 10%, transparent)}.iris-btn--danger-solid{background:var(--eval-fail);border-color:var(--eval-fail);color:#fff;font-weight:600}.iris-btn--danger-solid:not(:disabled):hover{background:#dc2626;border-color:#dc2626}.iris-btn--sm{padding:var(--space-0_5) var(--space-2_5);font-size:var(--text-caption)}.iris-btn--mono{font-family:var(--font-mono)}.iris-kbd{font-size:var(--text-caption);font-family:var(--font-mono);background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-xs);padding:2px var(--space-2);color:var(--text-muted)}.iris-card{background:var(--bg-card);border:1px solid var(--border-default);border-radius:var(--radius-lg);transition:background-color var(--transition-fast), border-color var(--transition-fast)}.iris-card--hover:hover,.iris-card--hover:focus-within{border-color:var(--border-strong)}.iris-stack{gap:var(--space-3);flex-direction:column;display:flex}.iris-stack--lg{gap:var(--space-6)}.iris-row{align-items:center;gap:var(--space-3);display:flex}.iris-grid-kpis{gap:var(--space-3);grid-template-columns:repeat(auto-fit,minmax(180px,1fr));display:grid}.iris-grid-split{gap:var(--space-3);grid-template-columns:repeat(auto-fit,minmax(340px,1fr));display:grid}.iris-grid-gauge{gap:var(--space-3);grid-template-columns:minmax(280px,1fr) minmax(0,1.8fr);align-items:stretch;display:grid}@media (width<=960px){.iris-grid-gauge{grid-template-columns:1fr}}.iris-num{font-variant-numeric:tabular-nums}.iris-num--right{text-align:right}.iris-error-box{background:color-mix(in srgb, var(--eval-fail) 12%, transparent);border:1px solid var(--eval-fail);border-radius:var(--radius);padding:var(--space-4);color:var(--eval-fail);gap:var(--space-2);flex-direction:column;display:flex}.iris-backdrop{z-index:110;background:oklch(0% 0 0/.55);justify-content:center;align-items:flex-start;display:flex;position:fixed;inset:0}.iris-modal{background:var(--bg-card);border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);flex-direction:column;display:flex;overflow:hidden}.moment-card{gap:var(--space-3);padding:var(--space-4);color:var(--text-primary);border-left:3px solid var(--moment-sig-color,transparent);grid-template-columns:24px 48px 1fr auto;align-items:start;display:grid}.moment-card:hover,.moment-card:focus-within{background:var(--bg-card-hover);border-left-color:var(--moment-sig-color,transparent)}.moment-card--archived{opacity:.5}.moment-card--selected{background:var(--bg-card-hover);border-color:var(--iris-500);border-left-color:var(--moment-sig-color,transparent)}.moment-card__checkbox-wrap{padding-top:var(--space-1);justify-content:center;align-items:center;display:flex}.moment-card__checkbox{cursor:pointer;width:16px;height:16px;accent-color:var(--iris-500)}.moment-card__rail{align-items:center;gap:var(--space-1);flex-direction:column;display:flex}.moment-card__glyph{width:32px;height:32px;font-size:var(--text-body);font-family:var(--font-mono);color:var(--bg-base);border-radius:50%;justify-content:center;align-items:center;font-weight:700;display:flex}.moment-card__body-link{color:inherit;gap:var(--space-2);flex-direction:column;min-width:0;text-decoration:none;display:flex}.moment-card__body-link:hover{color:inherit}.moment-card__header{align-items:center;gap:var(--space-2);flex-wrap:wrap;display:flex}.moment-card__agent{font-size:var(--text-body-sm);color:var(--text-primary);font-weight:600}.moment-card__sig{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-secondary);text-overflow:ellipsis;white-space:nowrap;font-weight:500;overflow:hidden}.moment-card__verdict{font-size:var(--text-caption);font-family:var(--font-mono);letter-spacing:.05em;font-weight:700}.moment-card__tag{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);background:var(--bg-surface);border:1px solid var(--border-default);padding:0 var(--space-2);border-radius:var(--radius-xs)}.moment-card__tag--new{letter-spacing:.05em;color:var(--text-accent);background:var(--glow-primary);border-color:var(--iris-500);font-weight:700}.moment-card__preview{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.moment-card__chips{gap:var(--space-1);flex-wrap:wrap;display:flex}.moment-card__chip{font-size:var(--text-caption);font-family:var(--font-mono);padding:1px var(--space-1_5);border-radius:var(--radius-xs);background:color-mix(in srgb, var(--eval-fail) 12%, transparent);color:var(--eval-fail)}.moment-card__meta{align-items:flex-end;gap:var(--space-1);font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);flex-direction:column;display:flex}.view-tabs{align-items:center;gap:var(--space-1);border-bottom:1px solid var(--border-subtle);margin-bottom:var(--space-5);display:flex}.view-tabs__tab{align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);font-family:var(--font-display);font-size:var(--text-body-sm);letter-spacing:-.01em;color:var(--text-muted);cursor:pointer;transition:color var(--transition-fast), border-color var(--transition-fast);border-bottom:2px solid #0000;margin-bottom:-1px;font-weight:600;text-decoration:none;display:inline-flex}.view-tabs__tab:hover{color:var(--text-primary)}.view-tabs__tab[aria-selected=true]{color:var(--text-primary);border-bottom-color:var(--iris-500)}.view-tabs__hint{font-size:var(--text-caption-xs);color:var(--text-muted);font-family:var(--font-mono);padding-left:var(--space-3);margin-left:auto}.cmdk-backdrop{padding-top:15vh}.cmdk__panel{width:min(640px,100% - 32px);max-height:70vh}.cmdk__input-wrap{padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border-default);align-items:center;gap:var(--space-2);display:flex}.cmdk__prompt{font-family:var(--font-mono);color:var(--text-muted);font-size:var(--text-body-sm)}.cmdk__input{color:var(--text-primary);font-size:var(--text-body);background:0 0;border:none;outline:none;flex:1;font-family:inherit}.cmdk__list{padding:var(--space-2);flex:1;overflow:auto}.cmdk__section-title{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;padding:var(--space-2) var(--space-3)}.cmdk__item{justify-content:space-between;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);cursor:pointer;color:var(--text-secondary);display:flex}.cmdk__item:hover,.cmdk__item[aria-selected=true]{background:var(--bg-card-hover);color:var(--text-primary)}.cmdk__item-body{flex-direction:column;gap:2px;min-width:0;display:flex}.cmdk__item-title{font-size:var(--text-body-sm);font-weight:500}.cmdk__item-subtitle{font-size:var(--text-caption);color:var(--text-muted);font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.cmdk__empty{padding:var(--space-6);text-align:center;color:var(--text-muted);font-size:var(--text-body-sm)}.cmdk__empty-clear{color:var(--text-accent);cursor:pointer;font-size:inherit;background:0 0;border:none;font-family:inherit;text-decoration:underline}.cmdk__footer{gap:var(--space-3);font-size:var(--text-caption);color:var(--text-muted);font-family:var(--font-mono);padding:var(--space-2) var(--space-4);border-top:1px solid var(--border-default);background:var(--bg-surface);display:flex}.cmdk__footer-count{margin-left:auto}.confirm-dialog__panel{width:min(440px,100% - 32px);padding:var(--space-5);gap:var(--space-3);margin-top:20vh}.confirm-dialog__title{font-family:var(--font-display);font-size:var(--text-heading-sm);letter-spacing:-.01em;color:var(--text-primary);margin:0;font-weight:600}.confirm-dialog__body{font-size:var(--text-body-sm);color:var(--text-secondary);line-height:var(--leading-body);margin:0}.confirm-dialog__error{font-size:var(--text-body-sm);color:var(--eval-fail);margin:0}.confirm-dialog__actions{justify-content:flex-end;gap:var(--space-2);margin-top:var(--space-2);display:flex}.stat-tile{padding:var(--space-4) var(--space-5);gap:var(--space-2);flex-direction:column;min-height:110px;display:flex}.stat-tile__header{align-items:center;gap:var(--space-2);font-size:var(--text-caption-xs);font-weight:600;font-family:var(--font-body);text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);display:flex}.stat-tile__value-row{align-items:baseline;gap:var(--space-2);display:flex}.stat-tile__value{font-family:var(--font-display);font-size:var(--text-display-sm);font-weight:700;line-height:var(--leading-display);letter-spacing:-.02em;font-variant-numeric:tabular-nums}.stat-tile__delta{font-family:var(--font-mono);font-size:var(--text-caption);font-weight:500}.stat-tile__sub{font-size:var(--text-caption);color:var(--text-secondary)}.section-header{padding-top:var(--space-3);margin-bottom:var(--space-1);flex-direction:column;gap:2px;display:flex}.section-header__top{justify-content:space-between;align-items:baseline;gap:var(--space-3);flex-wrap:wrap;display:flex}.section-header__title{font-family:var(--font-display);font-size:var(--text-heading-sm);letter-spacing:-.015em;color:var(--text-primary);margin:0;font-weight:600}.section-header__trailing{font-family:var(--font-mono);font-size:var(--text-caption);color:var(--text-muted)}.section-header__question{font-size:var(--text-caption);color:var(--text-muted);margin:0}.detail-back{color:var(--text-muted);font-size:var(--text-body-sm);align-items:center;gap:var(--space-1);width:fit-content;transition:color var(--transition-instant);text-decoration:none;display:inline-flex}.detail-back:hover{color:var(--text-primary)}.detail-card{padding:var(--space-5)}.detail-card__grid{gap:var(--space-4);font-size:var(--text-body-sm);grid-template-columns:repeat(auto-fit,minmax(200px,1fr));display:grid}.detail-card__label{color:var(--text-muted);font-size:var(--text-caption);font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.05em}.detail-section{gap:var(--space-3);flex-direction:column;display:flex}.detail-section__title{font-family:var(--font-display);font-size:var(--text-heading-sm);color:var(--text-primary);letter-spacing:-.01em;font-weight:600;line-height:var(--leading-heading);margin:0}.eval-card{padding:var(--space-4);gap:var(--space-3);flex-direction:column;display:flex}.eval-card__badges{align-items:center;gap:var(--space-3);display:flex}.eval-card__rules{gap:var(--space-2);flex-direction:column;display:flex}.eval-card__rule{align-items:center;gap:var(--space-2);font-size:var(--text-body-sm);padding:var(--space-1) var(--space-2);background:var(--bg-base);border-radius:var(--radius-xs);display:flex}.eval-card__rule-mark{width:16px}.eval-card__rule-name{color:var(--text-secondary);font-size:var(--text-caption)}.eval-card__rule-message{color:var(--text-muted);font-size:var(--text-caption);flex:1}.eval-card__suggestions{font-size:var(--text-body-sm)}.eval-card__suggestions-label{color:var(--text-muted);margin-bottom:var(--space-1)}.eval-card__suggestions-list{padding-left:var(--space-5);color:var(--text-secondary);margin:0}.rule-card{padding:var(--space-4);gap:var(--space-3);grid-template-columns:1fr auto;align-items:start;display:grid}.rule-card__body{gap:var(--space-2);flex-direction:column;min-width:0;display:flex}.rule-card__name-row{gap:var(--space-2);flex-wrap:wrap;align-items:baseline;display:flex}.rule-card__name{font-size:var(--text-body);font-weight:600;font-family:var(--font-mono);color:var(--text-primary)}.rule-card__description{font-size:var(--text-body-sm);color:var(--text-secondary);margin:0}.rule-card__meta{gap:var(--space-3);font-size:var(--text-caption);color:var(--text-muted);font-family:var(--font-mono);flex-wrap:wrap;display:flex}.rule-card__badge{font-size:var(--text-caption);font-family:var(--font-mono);padding:1px var(--space-1_5);border-radius:var(--radius-xs);background:var(--bg-surface);color:var(--text-secondary)}.rule-card__source-link{color:var(--text-accent);font-size:var(--text-caption);font-family:var(--font-mono);text-decoration:underline}.rule-card__actions{align-items:flex-end;gap:var(--space-2);flex-direction:column;display:flex}.rule-card__toggle{align-items:center;gap:var(--space-2);display:flex}.rule-card__state{font-size:var(--text-caption);font-family:var(--font-mono);color:var(--text-muted)}.rule-card__state--on{color:var(--eval-pass)}.rule-card__toggle-error{font-size:var(--text-caption);color:var(--eval-fail);text-align:right;max-width:260px}.iris-switch{appearance:none;border-radius:var(--radius-pill);border:1px solid var(--border-strong);background:var(--bg-surface);cursor:pointer;width:36px;height:20px;transition:background-color var(--transition-instant), border-color var(--transition-instant);padding:0;position:relative}.iris-switch__knob{background:var(--text-muted);width:14px;height:14px;transition:transform var(--transition-instant), background-color var(--transition-instant);border-radius:50%;position:absolute;top:2px;left:2px}.iris-switch--on{background:var(--iris-600);border-color:var(--iris-600)}.iris-switch--on .iris-switch__knob{background:#fff;transform:translate(16px)}.iris-switch:disabled,.iris-switch[aria-busy=true]{opacity:.5;cursor:default}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-body);font-size:var(--text-body);color:var(--text-primary);background-color:var(--bg-base);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;font-feature-settings:"cv11", "ss01";font-variant-numeric:tabular-nums}h1,h2,h3,h4,.display{font-family:var(--font-display);letter-spacing:-.01em;font-weight:600}a{color:var(--text-accent);text-decoration:none}a:hover{color:var(--iris-300)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--iris-500);outline-offset:2px;border-radius:var(--radius-xs)}.iris-sr-reveal{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.iris-sr-reveal:focus-within{width:auto;height:auto;margin:var(--space-3) 0 0 0;padding:var(--space-3) var(--space-4);clip:auto;white-space:normal;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--iris-500);border-radius:var(--radius-sm);position:static;overflow:visible}.iris-sr-reveal:focus-within>li{padding:var(--space-1) 0;list-style:none}::selection{background:var(--iris-600);color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-base)}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:var(--radius-xs)}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}html{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}@keyframes pulse-ring{0%{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(2.5)}}.pulse-dot{position:relative}.pulse-dot:after{content:"";background:var(--iris-500);border-radius:50%;animation:2s ease-out infinite pulse-ring;position:absolute;inset:-2px}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (width<=767px){aside[aria-label=Main\ navigation]{width:160px}main{overflow-x:auto}}@media print{body{color:#000!important;background:#fff!important}aside[aria-label=Main\ navigation],header,[role=region][aria-label=Welcome],[role=region][aria-label=Bulk\ actions],[role=status],[role=dialog]{display:none!important}body,#root,main{height:auto!important;overflow:visible!important}main{padding:0!important}tr,pre,code{page-break-inside:avoid}h1,h2,h3{page-break-after:avoid}[aria-label*=violation],[aria-label*=spike],[aria-label*=collision],[aria-label*=Pass],[aria-label*=Fail]{border:1px solid #000!important}a{color:#000!important;text-decoration:underline!important}a[href^=http]:after{content:" (" attr(href) ")";color:#555;font-size:80%}}
@@ -4,9 +4,10 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
- <title>IrisAgent Eval & Observability</title>
8
- <script type="module" crossorigin src="/assets/index-BZZt8bVh.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-UffZ-aEJ.css">
7
+ <!-- Stop shipping agents on vibes is filled from .claims.json brand.tagline at build time (vite.config.ts) never restate the tagline here. -->
8
+ <title>Iris Stop shipping agents on vibes</title>
9
+ <script type="module" crossorigin src="/assets/index-CshLgDRB.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-D0cFfBqn.css">
10
11
  </head>
11
12
  <body>
12
13
  <div id="root"></div>
@@ -11,13 +11,20 @@ export function registerHealthRoutes(router, storage, version) {
11
11
  * Cloud too) and reports server-level stats. This is the ONE
12
12
  * place storage is called with an explicit LOCAL_TENANT rather
13
13
  * than a resolved tenantId; deliberate, documented here so it
14
- * stays the only exception. */
15
- const summary = await storage.getDashboardSummary(LOCAL_TENANT, 1);
14
+ * stays the only exception.
15
+ *
16
+ * trace_count is ALL-TIME. It used to read the dashboard
17
+ * summary's 1-hour window, so /health said 0 while /api/v1/traces
18
+ * said 253 on the same demo database (#373 item 1) — a liveness
19
+ * field that disagrees with the data it fronts is worse than
20
+ * none. queryTraces' `total` is the unfiltered COUNT(*) for the
21
+ * tenant; limit 1 keeps the row fetch negligible. */
22
+ const { total } = await storage.queryTraces(LOCAL_TENANT, { limit: 1, offset: 0 });
16
23
  res.json({
17
24
  status: 'ok',
18
25
  version: serverVersion,
19
26
  uptime_seconds,
20
- trace_count: summary.total_traces,
27
+ trace_count: total,
21
28
  storage: 'connected',
22
29
  });
23
30
  }
@@ -47,7 +47,7 @@ export function registerMomentRoutes(router, storage) {
47
47
  sort_order: query.sort_order,
48
48
  });
49
49
  // Hydrate moments by fetching evals per trace. Acceptable for limit ≤ 200;
50
- // batching is a v0.4.1 optimization once we have moment-volume data.
50
+ // batching is a later optimization once we have moment-volume data.
51
51
  const moments = [];
52
52
  for (const trace of traceResult.traces) {
53
53
  const evals = await storage.getEvalsByTraceId(tenantId, trace.trace_id);
@@ -1,3 +1,4 @@
1
1
  import { Router } from 'express';
2
2
  import type { PreferenceStore } from '../../preferences.js';
3
+ export declare function preferencesDisplayPath(absolutePath: string): string;
3
4
  export declare function registerPreferencesRoutes(router: Router, store: PreferenceStore): void;
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { basename, isAbsolute, relative, sep } from 'node:path';
3
+ import { irisHome } from '../../utils/iris-home.js';
2
4
  /* PATCH body schema. Every field optional + strict (rejects unknown keys
3
5
  * to catch typos at the API boundary). Inner schemas mirror preferences.ts. */
4
6
  const MomentFiltersPatchSchema = z
@@ -31,20 +33,46 @@ const PatchSchema = z
31
33
  notificationsLastSeen: z.string().datetime({ offset: true }).optional(),
32
34
  })
33
35
  .strict();
36
+ /*
37
+ * The preferences file's location, in a form safe to show in a browser.
38
+ *
39
+ * Two constraints pull against each other. The welcome banner used to
40
+ * hardcode `~/.iris/preferences.json`, which is wrong whenever IRIS_HOME
41
+ * points elsewhere and always wrong in --demo mode (demo-preferences.json)
42
+ * (#377 item 2). But the absolute path must NOT go over the wire: it
43
+ * embeds the OS username (install-path disclosure, CWE-209 — #334, the
44
+ * reason store.path was removed from these responses in the first place).
45
+ *
46
+ * So the response carries a DISPLAY path: the real file name under the
47
+ * real home, with the home spelled the way the user configured it —
48
+ * `$IRIS_HOME/…` when the env var is set, `~/.iris/…` otherwise. A store
49
+ * pointed outside the iris home (embedders, tests) shows the bare file
50
+ * name rather than any directory.
51
+ */
52
+ export function preferencesDisplayPath(absolutePath) {
53
+ const home = irisHome();
54
+ const rel = relative(home, absolutePath);
55
+ const insideHome = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
56
+ if (!insideHome)
57
+ return basename(absolutePath);
58
+ const prefix = process.env.IRIS_HOME ? '$IRIS_HOME' : '~/.iris';
59
+ return `${prefix}/${rel.split(sep).join('/')}`;
60
+ }
34
61
  export function registerPreferencesRoutes(router, store) {
35
62
  /*
36
63
  * Responses deliberately omit store.path: the absolute path embeds the
37
64
  * OS username (install-path disclosure, CWE-209 — same class as PR
38
- * #286) and the frontend never read it.
65
+ * #286) and the frontend never read it. `displayPath` is the
66
+ * username-free form (see preferencesDisplayPath).
39
67
  */
40
68
  router.get('/preferences', (_req, res) => {
41
- res.json({ preferences: store.read() });
69
+ res.json({ preferences: store.read(), displayPath: preferencesDisplayPath(store.path) });
42
70
  });
43
71
  router.patch('/preferences', (req, res) => {
44
72
  try {
45
73
  const patch = PatchSchema.parse(req.body);
46
74
  const updated = store.patch(patch);
47
- res.json({ preferences: updated });
75
+ res.json({ preferences: updated, displayPath: preferencesDisplayPath(store.path) });
48
76
  }
49
77
  catch (err) {
50
78
  if (err instanceof z.ZodError) {
@@ -2,9 +2,27 @@ import { Router } from 'express';
2
2
  import type { IStorageAdapter } from '../../types/query.js';
3
3
  import type { CustomRuleStore } from '../../custom-rule-store.js';
4
4
  import type { EvalEngine } from '../../eval/engine.js';
5
+ import type { EvalType } from '../../types/eval.js';
5
6
  interface RoutesOptions {
6
7
  customRuleStore: CustomRuleStore;
7
8
  evalEngine: EvalEngine;
8
9
  }
10
+ /** Built-in rule metadata as the dashboard sees it — derived from the engine, never restated. */
11
+ export interface BuiltInRuleMeta {
12
+ name: string;
13
+ category: EvalType;
14
+ description: string;
15
+ weight: number;
16
+ critical: boolean;
17
+ }
18
+ export declare function listBuiltInRules(): BuiltInRuleMeta[];
9
19
  export declare function registerRuleRoutes(router: Router, storage: IStorageAdapter, opts: RoutesOptions): void;
20
+ /** Verdict of the proposed rule against the caller's own sample text. */
21
+ export interface SamplePreview {
22
+ passed: boolean;
23
+ score: number;
24
+ message: string;
25
+ skipped: boolean;
26
+ skipReason?: string;
27
+ }
10
28
  export {};
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { createCustomRule } from '../../eval/rules/custom.js';
3
+ import { rulesByType } from '../../eval/rules/index.js';
3
4
  import { requireTenant } from '../../middleware/tenant.js';
5
+ import { DuplicateRuleNameError, replacedRulesWarning, retireSameNamedRules } from '../../tools/deploy-rule.js';
6
+ import { strictBody } from '../validation.js';
4
7
  const SeveritySchema = z.enum(['low', 'medium', 'high', 'critical']);
5
8
  const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
6
9
  const RuleTypeSchema = z.enum([
@@ -13,28 +16,82 @@ const RuleTypeSchema = z.enum([
13
16
  'json_schema',
14
17
  'cost_threshold',
15
18
  ]);
16
- const DefinitionSchema = z.object({
17
- name: z.string().min(1).max(80),
19
+ /*
20
+ * Strict at every level the dashboard owns. `config` stays a free-form
21
+ * record on purpose (each rule type reads its own keys and the store
22
+ * validates them per type); everything around it has a fixed key set, so
23
+ * a misspelled `wieght` is rejected instead of silently dropped (#376).
24
+ *
25
+ * `definition.name` is optional: the server always overwrites it with the
26
+ * top-level rule name (#377 item 3), so requiring a value that is then
27
+ * discarded only invited confusion.
28
+ */
29
+ const DefinitionSchema = strictBody({
30
+ name: z
31
+ .string()
32
+ .min(1)
33
+ .max(80)
34
+ .optional()
35
+ .describe('Optional — always replaced by the top-level rule name on deploy'),
18
36
  type: RuleTypeSchema,
19
37
  config: z.record(z.string(), z.unknown()),
20
38
  weight: z.number().positive().optional(),
21
39
  });
22
- const DeploySchema = z.object({
40
+ const DeploySchema = strictBody({
23
41
  name: z.string().min(1).max(80).regex(/^[a-z0-9._-]+$/i, 'Use letters, digits, dot, dash, underscore'),
24
42
  description: z.string().max(500).optional(),
25
43
  evalType: EvalTypeSchema,
26
44
  severity: SeveritySchema.optional(),
27
45
  definition: DefinitionSchema,
28
46
  sourceMomentId: z.string().optional(),
47
+ /**
48
+ * Same contract as deploy_rule's `replace`: a name that is already
49
+ * deployed is refused (409) unless this is true, in which case the
50
+ * existing same-named rule(s) are deleted and this one takes their place.
51
+ */
52
+ replace: z.boolean().default(false),
29
53
  });
30
- const PreviewSchema = z.object({
54
+ /*
55
+ * Bounded well under the 1mb body limit. The sample is evaluated exactly
56
+ * once, on the main thread, inside the same regex sandbox every other
57
+ * evaluation uses — the cap keeps one request's worst case small.
58
+ */
59
+ const MAX_SAMPLE_OUTPUT_CHARS = 100_000;
60
+ const PreviewSchema = strictBody({
31
61
  definition: DefinitionSchema,
32
62
  evalType: EvalTypeSchema.default('custom'),
33
63
  /** Window in days; default 7. Hard cap at 30 to keep memory bounded. */
34
64
  windowDays: z.coerce.number().int().min(1).max(30).default(7),
35
65
  /** Trace cap; default 1000. */
36
66
  maxTraces: z.coerce.number().int().min(1).max(5000).default(1000),
67
+ /**
68
+ * Dry-run text. When present the rule is ALSO evaluated against this
69
+ * exact output and the verdict is returned under `sample` — the
70
+ * "validation against sample output" deploy_rule's description has
71
+ * promised since v0.4. The endpoint used to accept the key and ignore it
72
+ * (#373 item 2): a caller got back the historical replay only, with no
73
+ * hint that their sample was never looked at.
74
+ */
75
+ sampleOutput: z.string().max(MAX_SAMPLE_OUTPUT_CHARS).optional(),
76
+ });
77
+ const ToggleSchema = strictBody({
78
+ enabled: z.boolean(),
37
79
  });
80
+ export function listBuiltInRules() {
81
+ const out = [];
82
+ for (const [category, rules] of Object.entries(rulesByType)) {
83
+ for (const rule of rules) {
84
+ out.push({
85
+ name: rule.name,
86
+ category,
87
+ description: rule.description,
88
+ weight: rule.weight,
89
+ critical: rule.critical === true,
90
+ });
91
+ }
92
+ }
93
+ return out;
94
+ }
38
95
  export function registerRuleRoutes(router, storage, opts) {
39
96
  /*
40
97
  * Every /rules/custom route resolves the tenant via requireTenant(req)
@@ -44,6 +101,19 @@ export function registerRuleRoutes(router, storage, opts) {
44
101
  * each tenant's rules will live in their own file (per-tenant partition)
45
102
  * and writes will only ever touch the resolved tenant's data.
46
103
  */
104
+ /*
105
+ * The engine's own rule roster. The dashboard used to carry a hand-
106
+ * copied name → category map and classify "safety" by name substrings
107
+ * (pii / injection / blocklist / stub), which is how a
108
+ * no_hallucination_markers failure — a safety rule since v0.5.0 — drilled
109
+ * through to the wrong filter on two charts. Serving the roster from
110
+ * `rulesByType` means a rule added to a bundle is categorised correctly
111
+ * on the dashboard without a second edit. Not tenant-scoped: built-ins
112
+ * are process-global.
113
+ */
114
+ router.get('/rules/builtin', (_req, res) => {
115
+ res.json({ rules: listBuiltInRules() });
116
+ });
47
117
  router.get('/rules/custom', (req, res) => {
48
118
  const tenantId = requireTenant(req);
49
119
  const rules = opts.customRuleStore.list(tenantId);
@@ -60,6 +130,15 @@ export function registerRuleRoutes(router, storage, opts) {
60
130
  ...input.definition,
61
131
  name: input.name,
62
132
  };
133
+ /*
134
+ * Same-name redeploy — the SAME helper deploy_rule uses, so the
135
+ * dashboard cannot accept a duplicate the tool refuses (two rules
136
+ * with one name both fire with indistinguishable rule_results).
137
+ * Throws before anything is written when the name is taken and
138
+ * `replace` is false; with `replace: true` the earlier rule(s) are
139
+ * deleted (audit rows kept, unregistered from the engine) beforehand.
140
+ */
141
+ const replaced = retireSameNamedRules(opts.customRuleStore, opts.evalEngine, tenantId, input.name, input.replace, 'local');
63
142
  const rule = opts.customRuleStore.deploy(tenantId, {
64
143
  name: input.name,
65
144
  description: input.description,
@@ -75,13 +154,69 @@ export function registerRuleRoutes(router, storage, opts) {
75
154
  // v0.5 architectural item. Severity rides along: high/critical makes
76
155
  // the rule hard-failing (same as the MCP deploy path and boot loading).
77
156
  opts.evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
78
- res.status(201).json({ rule });
157
+ res.status(201).json({
158
+ rule,
159
+ ...(replaced.length > 0 ? { replaced, warning: replacedRulesWarning(input.name, replaced) } : {}),
160
+ });
79
161
  }
80
162
  catch (err) {
81
163
  if (err instanceof z.ZodError) {
82
164
  res.status(400).json({ error: 'Invalid rule definition', details: err.issues });
83
165
  return;
84
166
  }
167
+ if (err instanceof DuplicateRuleNameError) {
168
+ // 409, not 400: the body was valid — the name is taken.
169
+ res.status(409).json({
170
+ error: err.message,
171
+ existing: err.existing.map((r) => ({ id: r.id, evalType: r.evalType, severity: r.severity, enabled: r.enabled })),
172
+ });
173
+ return;
174
+ }
175
+ throw err;
176
+ }
177
+ });
178
+ /*
179
+ * Enable / disable without deleting. delete_rule's and list_rules' tool
180
+ * descriptions have pointed users at "the dashboard's toggle affordance"
181
+ * since v0.4; until now nothing — no route, no UI call, no MCP tool —
182
+ * invoked the store's setEnabled, so the advertised affordance did not
183
+ * exist. The engine is kept in lockstep the same way delete is: a
184
+ * disabled rule stops firing on the very next evaluate_output call, and a
185
+ * re-enabled one fires again, no restart in either direction.
186
+ */
187
+ router.patch('/rules/custom/:id', (req, res) => {
188
+ try {
189
+ const tenantId = requireTenant(req);
190
+ const { enabled } = ToggleSchema.parse(req.body);
191
+ const existing = opts.customRuleStore.get(tenantId, req.params.id);
192
+ if (!existing) {
193
+ res.status(404).json({ error: 'Rule not found' });
194
+ return;
195
+ }
196
+ if (existing.enabled === enabled) {
197
+ // Idempotent: the engine already matches the store; touching it
198
+ // would double-register or no-op-unregister.
199
+ res.json({ rule: existing });
200
+ return;
201
+ }
202
+ const rule = opts.customRuleStore.setEnabled(tenantId, req.params.id, enabled);
203
+ if (!rule) {
204
+ res.status(404).json({ error: 'Rule not found' });
205
+ return;
206
+ }
207
+ if (enabled) {
208
+ opts.evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
209
+ }
210
+ else {
211
+ opts.evalEngine.unregisterRule(rule.id);
212
+ }
213
+ res.json({ rule });
214
+ }
215
+ catch (err) {
216
+ if (err instanceof z.ZodError) {
217
+ res.status(400).json({ error: 'Invalid toggle request', details: err.issues });
218
+ return;
219
+ }
85
220
  throw err;
86
221
  }
87
222
  });
@@ -128,7 +263,9 @@ async function previewRule(tenantId, input, storage) {
128
263
  sort_by: 'timestamp',
129
264
  sort_order: 'desc',
130
265
  });
131
- const rule = createCustomRule(input.definition);
266
+ // definition.name is optional on the wire (the deploy path overwrites it
267
+ // anyway); the evaluator wants a name for its result rows.
268
+ const rule = createCustomRule({ ...input.definition, name: input.definition.name ?? 'preview' });
132
269
  // Sanity-probe the rule against an empty input. A broken DEFINITION —
133
270
  // invalid regex, pattern too long, ReDoS rejection, missing required
134
271
  // config — comes back marked configInvalid (custom.ts routes every
@@ -189,6 +326,22 @@ async function previewRule(tenantId, input, storage) {
189
326
  }
190
327
  }
191
328
  }
329
+ /*
330
+ * The sample gets its own budget: it is the one output the author is
331
+ * actually asking about, and it must not inherit a breaker the replay
332
+ * may already have opened.
333
+ */
334
+ let sample;
335
+ if (input.sampleOutput !== undefined) {
336
+ const r = rule.evaluate({ output: input.sampleOutput, regexBudget: { breaches: 0 } });
337
+ sample = {
338
+ passed: r.passed,
339
+ score: r.score,
340
+ message: r.message,
341
+ skipped: r.skipped === true,
342
+ ...(r.skipReason !== undefined ? { skipReason: r.skipReason } : {}),
343
+ };
344
+ }
192
345
  return {
193
346
  tracesEvaluated: traceResult.traces.length,
194
347
  wouldFail,
@@ -196,5 +349,6 @@ async function previewRule(tenantId, input, storage) {
196
349
  wouldSkip,
197
350
  examples,
198
351
  windowSinceIso: since,
352
+ ...(sample ? { sample } : {}),
199
353
  };
200
354
  }
@@ -23,7 +23,8 @@ export function registerTraceRoutes(router, storage, options) {
23
23
  return;
24
24
  }
25
25
  // Server-minted, exactly like log_trace — a client-supplied
26
- // trace_id was already stripped by the schema.
26
+ // trace_id was already REJECTED by the strict schema above (400,
27
+ // with a message saying the server mints it).
27
28
  const traceId = generateTraceId();
28
29
  const timestamp = body.timestamp ?? new Date().toISOString();
29
30
  const trace = {
@@ -57,12 +58,19 @@ export function registerTraceRoutes(router, storage, options) {
57
58
  }
58
59
  // Deterministic engine, same context evaluate_output builds. The
59
60
  // superRefine on ingestTraceSchema guarantees output is present.
60
- const evaluation = options.evalEngine.evaluate(body.eval_type, {
61
+ // eval_type="all" takes the same every-bundle path the MCP tool
62
+ // takes (evaluateAll): one pass, one regex budget, the critical veto
63
+ // spanning every bundle, and a per-category breakdown — stored under
64
+ // eval_type "all" so the dashboard reads it as the tool's rows.
65
+ const context = {
61
66
  output: body.output,
62
67
  input: body.input,
63
68
  costUsd: body.cost_usd,
64
69
  tokenUsage: body.token_usage,
65
- });
70
+ };
71
+ const evaluation = body.eval_type === 'all'
72
+ ? options.evalEngine.evaluateAll(context)
73
+ : options.evalEngine.evaluate(body.eval_type, context);
66
74
  evaluation.trace_id = traceId;
67
75
  await storage.insertEvalResult(tenantId, evaluation);
68
76
  res.status(201).json({
@@ -78,6 +86,23 @@ export function registerTraceRoutes(router, storage, options) {
78
86
  rules_evaluated: evaluation.rules_evaluated,
79
87
  rules_skipped: evaluation.rules_skipped,
80
88
  insufficient_data: evaluation.insufficient_data,
89
+ /*
90
+ * The veto reason travels the ingest path too. Omitting it left an
91
+ * HTTP caller seeing passed:false beside a high score with no way
92
+ * to learn that a critical rule — not the weighted average —
93
+ * produced the verdict. Absent when nothing vetoed.
94
+ */
95
+ ...(evaluation.critical_failures?.length
96
+ ? { critical_failures: evaluation.critical_failures }
97
+ : {}),
98
+ // The other half of the veto contract, same as evaluate_output: a
99
+ // critical rule that SKIPPED did not judge the output and cannot
100
+ // veto, so a fail-closed gate needs to see it named.
101
+ ...(evaluation.critical_skipped?.length
102
+ ? { critical_skipped: evaluation.critical_skipped }
103
+ : {}),
104
+ // Per-bundle breakdown — eval_type="all" only.
105
+ ...(evaluation.categories ? { categories: evaluation.categories } : {}),
81
106
  },
82
107
  });
83
108
  }
@@ -99,6 +124,8 @@ export function registerTraceRoutes(router, storage, options) {
99
124
  framework: query.framework,
100
125
  since: query.since,
101
126
  until: query.until,
127
+ min_score: query.min_score,
128
+ max_score: query.max_score,
102
129
  },
103
130
  limit: query.limit,
104
131
  offset: query.offset,
@@ -255,7 +255,7 @@ const INJECTION_OUTPUTS = [
255
255
  },
256
256
  ];
257
257
  // Confident fabrications against provided source material — the failure
258
- // class the v0.4.7 no_hallucination_markers rewrite detects. The `input`
258
+ // class the v0.5.0 no_hallucination_markers rewrite detects. The `input`
259
259
  // carries the ask plus the material the agent was given; the output
260
260
  // asserts specifics that material never states. Evaluated with the REAL
261
261
  // rule (imported below), so the demo rows match live behavior exactly.
@@ -398,6 +398,17 @@ function generateTimestamp(dayIndex) {
398
398
  const minute = randomInt(0, 59);
399
399
  const second = randomInt(0, 59);
400
400
  dayStart.setHours(hour, minute, second, randomInt(0, 999));
401
+ /*
402
+ * The last day is TODAY, and the hour above is drawn from the whole day
403
+ * — so before this clamp a demo seeded at 09:00 carried traces stamped
404
+ * 22:00 tonight, which the timeline rendered as "just now" and the
405
+ * "new since you last looked" counter kept re-counting. A demo trace
406
+ * is never in the future: anything past the seed moment lands inside
407
+ * the hour before it (still seeded-random, still deterministic).
408
+ */
409
+ if (dayStart.getTime() > now.getTime()) {
410
+ return new Date(now.getTime() - randomInt(1, 3_600_000)).toISOString();
411
+ }
401
412
  return dayStart.toISOString();
402
413
  }
403
414
  function scoreRules(evalType, rules, weights) {
@@ -534,7 +545,7 @@ function simulateSafetyEval(output, input) {
534
545
  ? 'No injection patterns detected'
535
546
  : `Potential injection patterns detected: ${foundInjections} match(es)`,
536
547
  };
537
- // Hallucination is context-grounded (v0.4.7) — when the caller provides
548
+ // Hallucination is context-grounded (v0.5.0) — when the caller provides
538
549
  // input, run the REAL rule so the seeded row matches live behavior
539
550
  // exactly instead of mimicking it.
540
551
  if (input === undefined) {
@@ -846,7 +857,7 @@ export async function seedDemoData(options) {
846
857
  evalResult = simulateSafetyEval(output);
847
858
  }
848
859
  else if (specialType === 'hallucination') {
849
- // v0.4.7: hallucination detection lives in the safety bundle and
860
+ // v0.5.0: hallucination detection lives in the safety bundle and
850
861
  // grounds itself against the input.
851
862
  evalResult = simulateSafetyEval(output, input);
852
863
  }
@@ -7,7 +7,7 @@ import { irisHome } from '../utils/iris-home.js';
7
7
  import { createAuthMiddleware } from '../middleware/auth.js';
8
8
  import { createCorsMiddleware } from '../middleware/cors.js';
9
9
  import { createErrorHandler } from '../middleware/error-handler.js';
10
- import { createApiRateLimiter } from '../middleware/rate-limit.js';
10
+ import { createApiRateLimiter, createAuthGateRateLimiter } from '../middleware/rate-limit.js';
11
11
  import { createTenantMiddleware } from '../middleware/tenant.js';
12
12
  import { createRebindingGuard, isLoopbackHost } from '../middleware/rebinding-guard.js';
13
13
  import { registerTraceRoutes } from './routes/traces.js';
@@ -21,6 +21,7 @@ import { registerFailureRoutes } from './routes/failures.js';
21
21
  import { registerRuleRoutes } from './routes/rules.js';
22
22
  import { registerPreferencesRoutes } from './routes/preferences.js';
23
23
  import { registerAuditRoutes } from './routes/audit.js';
24
+ import { createSessionAuth } from './session-auth.js';
24
25
  export function createDashboardServer(storage, config, logger, options) {
25
26
  const app = express();
26
27
  // Security headers
@@ -56,8 +57,17 @@ export function createDashboardServer(storage, config, logger, options) {
56
57
  }));
57
58
  // CORS
58
59
  app.use(createCorsMiddleware(config.security.allowedOrigins));
59
- // Authentication
60
- app.use(createAuthMiddleware(config));
60
+ /*
61
+ * Authentication. The Bearer middleware is the contract for MCP clients
62
+ * and capture SDKs; the session layer in front of it is what lets a
63
+ * BROWSER present the same key once (`?key=` or the sign-in form) and
64
+ * then ride an HttpOnly cookie — without it, `--api-key` made the
65
+ * dashboard UI 401 on every page load (#373 item 6). With no key
66
+ * configured both are pass-throughs. The limiter ahead of it is the
67
+ * ceiling every authorization decision sits behind.
68
+ */
69
+ app.use(createAuthGateRateLimiter(config));
70
+ app.use(createSessionAuth({ apiKey: config.security.apiKey, bearerAuth: createAuthMiddleware(config) }));
61
71
  // Tenant resolution — attaches req.tenantId to every request.
62
72
  // OSS: always resolves to LOCAL_TENANT. Cloud: swaps for an auth-aware
63
73
  // resolver that reads the authenticated session. See middleware/tenant.ts.