loki-mode 10.6.14 → 10.7.1

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 (123) hide show
  1. package/README.md +9 -5
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/acp-agent.py +128 -0
  5. package/autonomy/docker-run.sh +8 -0
  6. package/autonomy/issue-parser.sh +7 -7
  7. package/autonomy/issue-providers.sh +159 -1
  8. package/autonomy/lib/engine10-push.sh +4 -1
  9. package/autonomy/lib/review_risk.py +91 -0
  10. package/autonomy/loki +209 -19
  11. package/autonomy/mobile-verify.sh +66 -0
  12. package/autonomy/run.sh +44 -2
  13. package/completions/_loki +2 -0
  14. package/completions/loki.bash +1 -1
  15. package/dashboard/__init__.py +1 -1
  16. package/dashboard/api_runs_v1.py +80 -0
  17. package/dashboard/server.py +2 -0
  18. package/docs/INSTALLATION.md +5 -1
  19. package/docs/acp-editors.md +29 -0
  20. package/docs/api-runs-v1.md +12 -0
  21. package/docs/dev/architecture-reference.md +4 -0
  22. package/docs/merge-queue.md +13 -0
  23. package/docs/mobile-verify.md +11 -0
  24. package/docs/sentry-intake.md +13 -0
  25. package/docs/v10/BOARD.md +31 -6
  26. package/docs/v10/CONTROL-PLANE.md +7 -0
  27. package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
  28. package/docs/v10/D61-SPEED.md +4 -0
  29. package/docs/v10/DECISIONS.md +115 -0
  30. package/docs/v10/ENGINE-LAWS.md +155 -0
  31. package/docs/v10/FAILURE-CLASSES.md +145 -0
  32. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  33. package/docs/v10/GAP-RESEARCH-1003.md +32 -0
  34. package/docs/v10/GUIDE.md +4 -2
  35. package/docs/v10/L0-WAVE1.md +81 -0
  36. package/docs/v10/MORNING-BRIEF-1003.md +25 -0
  37. package/docs/v10/OPERATING-MODEL.md +1 -0
  38. package/docs/v10/PROGRESS.md +99 -0
  39. package/docs/v10/SWARM.md +32 -0
  40. package/docs/visual-evidence.md +5 -0
  41. package/loki-ts/dist/loki.js +805 -777
  42. package/loki-ts/package.json +1 -0
  43. package/mcp/__init__.py +1 -1
  44. package/package.json +1 -1
  45. package/packages/control-plane/dist/server.js +549 -21
  46. package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
  47. package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
  48. package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
  49. package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
  50. package/packages/control-plane/drizzle/meta/_journal.json +14 -0
  51. package/packages/control-plane/src/db/migrate.ts +6 -2
  52. package/packages/control-plane/src/db/prune.ts +91 -0
  53. package/packages/control-plane/src/db/schema.ts +13 -0
  54. package/packages/control-plane/src/server/actions.ts +60 -0
  55. package/packages/control-plane/src/server/app.ts +59 -4
  56. package/packages/control-plane/src/server/integrity.ts +147 -0
  57. package/packages/control-plane/src/server/runs.ts +46 -4
  58. package/packages/control-plane/src/shipper/discover.ts +32 -3
  59. package/packages/control-plane/src/shipper/ship.ts +15 -0
  60. package/packages/control-plane/src/shipper/watch.ts +66 -0
  61. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +1 -0
  62. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +129 -0
  63. package/packages/control-plane/ui/dist/index.html +3 -3
  64. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  65. package/web-app/dist/assets/{AdminPage-oAFMNfKH.js → AdminPage-BJSU5hY1.js} +1 -1
  66. package/web-app/dist/assets/{Avatar-BA2gnzyf.js → Avatar-CSOSpw9c.js} +1 -1
  67. package/web-app/dist/assets/{Badge-Dsluv8n1.js → Badge-COr7vj0D.js} +1 -1
  68. package/web-app/dist/assets/{Button-WmW8A-iG.js → Button-DGoWFERp.js} +1 -1
  69. package/web-app/dist/assets/{CockpitPage-BR5o_RgL.js → CockpitPage-CBpWkNXY.js} +1 -1
  70. package/web-app/dist/assets/{ComparePage-3Eg-pKvO.js → ComparePage-BwT8vqrL.js} +1 -1
  71. package/web-app/dist/assets/{ErrorBoundary-DAfrnnsQ.js → ErrorBoundary-D7SicDDA.js} +1 -1
  72. package/web-app/dist/assets/{EvidenceReceiptPanel-wLZI4OTr.js → EvidenceReceiptPanel-bx-5liG2.js} +1 -1
  73. package/web-app/dist/assets/{GitHubIssuesPanel-BOuadisf.js → GitHubIssuesPanel-BAeeWLqY.js} +1 -1
  74. package/web-app/dist/assets/{GitHubPRsPanel-BTVoVpB3.js → GitHubPRsPanel-CssD8lGI.js} +1 -1
  75. package/web-app/dist/assets/{HomePage-s3GWdNEe.js → HomePage-SL5PT3m5.js} +2 -2
  76. package/web-app/dist/assets/{LoginPage-BfOZ566t.js → LoginPage-C8hFOZOM.js} +1 -1
  77. package/web-app/dist/assets/{MagicPage-9EZ83xOt.js → MagicPage-xHbHkrPf.js} +1 -1
  78. package/web-app/dist/assets/{MetricsPage-OJMOYhHP.js → MetricsPage-B_2CKFcC.js} +1 -1
  79. package/web-app/dist/assets/{NotFoundPage-_LoJ6KEv.js → NotFoundPage-CXwXMq35.js} +1 -1
  80. package/web-app/dist/assets/{ProjectPage-Duj5LSt6.js → ProjectPage-AaisMQI-.js} +3 -3
  81. package/web-app/dist/assets/{ProjectsPage-BBud3JrO.js → ProjectsPage-Cej5ResX.js} +1 -1
  82. package/web-app/dist/assets/{SettingsPage-C4zdgKno.js → SettingsPage-DKv4mn9H.js} +1 -1
  83. package/web-app/dist/assets/{ShowcasePage-Nb3ACjMR.js → ShowcasePage-Dkiyzne4.js} +1 -1
  84. package/web-app/dist/assets/{SystemSettingsPage-BDi49TDh.js → SystemSettingsPage-DaBLQ5dU.js} +1 -1
  85. package/web-app/dist/assets/{TeamsPage-HkcqkjLa.js → TeamsPage-B2yUr31k.js} +1 -1
  86. package/web-app/dist/assets/{TemplatesPage-CK796Y1F.js → TemplatesPage-CfYiANhU.js} +1 -1
  87. package/web-app/dist/assets/{TerminalOutput-BkvdNuT6.js → TerminalOutput-CMXoexLM.js} +1 -1
  88. package/web-app/dist/assets/{activity-DFgMgETR.js → activity-B0dzoZtY.js} +1 -1
  89. package/web-app/dist/assets/{bell-BInGGOMZ.js → bell-DFOTaSoo.js} +1 -1
  90. package/web-app/dist/assets/{bot-D43Yjsj4.js → bot-Dz-WSQyP.js} +1 -1
  91. package/web-app/dist/assets/{check-DFAGTLM9.js → check-L1QIRueJ.js} +1 -1
  92. package/web-app/dist/assets/{chevron-left-Drwin7n1.js → chevron-left-1TrnIEZS.js} +1 -1
  93. package/web-app/dist/assets/{circle-alert-DLWdb7V9.js → circle-alert-8Bf6xEDx.js} +1 -1
  94. package/web-app/dist/assets/{clock-Dsbi59u5.js → clock-DB8NE6P5.js} +1 -1
  95. package/web-app/dist/assets/{cloud-CUU-dXTP.js → cloud-DUSL3wZS.js} +1 -1
  96. package/web-app/dist/assets/{code-xml-CqIh7Wui.js → code-xml-DbjryWNl.js} +1 -1
  97. package/web-app/dist/assets/{database-DASN5Bfh.js → database-DrezrMSs.js} +1 -1
  98. package/web-app/dist/assets/{dollar-sign-ZvXzAihc.js → dollar-sign-CGbaLMzR.js} +1 -1
  99. package/web-app/dist/assets/{file-code-corner-ED_gN-N8.js → file-code-corner-Dfypa-r4.js} +1 -1
  100. package/web-app/dist/assets/{file-plus-BmBHndXt.js → file-plus-CzV5L2Gd.js} +1 -1
  101. package/web-app/dist/assets/{globe-Ck_iEzO4.js → globe-BoYjTwPQ.js} +1 -1
  102. package/web-app/dist/assets/{hammer-B8YKMard.js → hammer-WO-HuiyJ.js} +1 -1
  103. package/web-app/dist/assets/{index-D7SxmjVQ.js → index-CjIOUjnd.js} +3 -3
  104. package/web-app/dist/assets/{layers-CGVsj3wB.js → layers-Bn6jtKNk.js} +1 -1
  105. package/web-app/dist/assets/{loader-circle-FIH7UJDF.js → loader-circle-gERWRnmW.js} +1 -1
  106. package/web-app/dist/assets/{lock-vR8HoAnK.js → lock-BIiMLJq8.js} +1 -1
  107. package/web-app/dist/assets/{package-mZ6LFwXp.js → package-dLvDSVys.js} +1 -1
  108. package/web-app/dist/assets/{plus-CtnSVu0w.js → plus-BHM3ZLhZ.js} +1 -1
  109. package/web-app/dist/assets/{refresh-cw-BIapF6Gj.js → refresh-cw-BAp_alv6.js} +1 -1
  110. package/web-app/dist/assets/{rotate-ccw-LMkSsm21.js → rotate-ccw-CBhwq_MG.js} +1 -1
  111. package/web-app/dist/assets/{scroll-text-DQVlIhr9.js → scroll-text-DoS-6dfU.js} +1 -1
  112. package/web-app/dist/assets/{server-GqyhQxmz.js → server-5JgdDWQO.js} +1 -1
  113. package/web-app/dist/assets/{shield-alert-BrFTNJ-I.js → shield-alert-DjmlLT34.js} +1 -1
  114. package/web-app/dist/assets/{thumbs-up-lgwAEJFW.js → thumbs-up-BtiEXl3p.js} +1 -1
  115. package/web-app/dist/assets/{trash-2-C1cNysK4.js → trash-2-ClsWU_Vg.js} +1 -1
  116. package/web-app/dist/assets/{trending-down-DQFZ6j5s.js → trending-down-DPS4M7_X.js} +1 -1
  117. package/web-app/dist/assets/{trending-up-BJG_Zo8V.js → trending-up-D6OJ38-V.js} +1 -1
  118. package/web-app/dist/assets/{upload-BOY1aTpA.js → upload-DOCzLUpF.js} +1 -1
  119. package/web-app/dist/assets/{usePolling-DoAmsTNy.js → usePolling-CljQTWj_.js} +1 -1
  120. package/web-app/dist/assets/{user-iOfCe0_v.js → user-C0G82Weu.js} +1 -1
  121. package/web-app/dist/index.html +1 -1
  122. package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
  123. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +0 -114
@@ -1,11 +1,14 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
- import { Hono } from "hono";
3
+ import { Hono, type Context } from "hono";
4
4
  import { openDb } from "../db/migrate.ts";
5
5
  import { ingest } from "./ingest.ts";
6
6
  import { defaultAnswerDir, writeAnswer } from "./answer.ts";
7
- import { listRuns, runDetail } from "./runs.ts";
8
- import { hostGuard, tokenGuard } from "./auth.ts";
7
+ import { listRuns, recomputeLegacy, runDetail } from "./runs.ts";
8
+ import { hostGuard, isLoopbackHost, tokenGuard } from "./auth.ts";
9
+ import { backfill } from "../shipper/backfill.ts";
10
+ import { removeRun } from "../db/prune.ts";
11
+ import { planStart, registryRepos, spawnStart } from "./actions.ts";
9
12
 
10
13
  const MAX_BODY = 1_000_000;
11
14
 
@@ -13,9 +16,10 @@ const MAX_BODY = 1_000_000;
13
16
  const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
14
17
 
15
18
  /** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
16
- export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean }) {
19
+ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean; repoDir?: string; startBin?: string; spawnImpl?: typeof spawnStart }) {
17
20
  const uiDir = opts.uiDir ?? defaultUiDir();
18
21
  const { db, sqlite } = openDb(opts.dbPath);
22
+ recomputeLegacy(db);
19
23
  let ready = true;
20
24
  const answerDir = opts.answerDir ?? defaultAnswerDir();
21
25
  const app = new Hono();
@@ -66,6 +70,57 @@ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: st
66
70
  const r = writeAnswer(answerDir, source, run, body?.answer);
67
71
  return c.json(r.body, r.status);
68
72
  });
73
+ // Machine-touching actions exist ONLY on a loopback-bound server (otherwise they are never registered: 404).
74
+ // Each request must also come from a loopback peer (the real socket address, not the spoofable Host header; unknown peer fails closed) and carry JSON (blocks cross-site form posts).
75
+ const act = opts.loopbackOnly ? app : new Hono();
76
+ const peerIsLoopback = (c: Context): boolean => {
77
+ const ip = (c.env as { requestIP?: (r: Request) => { address?: string } | null } | undefined)?.requestIP?.(c.req.raw)?.address;
78
+ return typeof ip === "string" && /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/.test(ip);
79
+ };
80
+ const local = (c: Context) => peerIsLoopback(c) && isLoopbackHost(c.req.header("host")) && (c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json");
81
+ const repoDir = opts.repoDir ?? process.cwd();
82
+ const inflight = new Set<string>();
83
+ act.post("/v1/import", async (c) => {
84
+ if (!local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
85
+ const r = await backfill({
86
+ repoDir,
87
+ env: { LOKI_CONTROL_URL: "http://127.0.0.1", LOKI_CONTROL_TOKEN: opts.token },
88
+ fetchImpl: ((u: string, init: RequestInit) => app.fetch(new Request(u, { ...init, headers: { ...(init.headers as Record<string, string>), host: "127.0.0.1" } }))) as unknown as typeof fetch,
89
+ });
90
+ return c.json({ runs: r.runs, sent: r.sent, failed: r.failed });
91
+ });
92
+ // Remove one run (and its events). Loopback-only like /v1/start (never registered on a non-loopback server); bearer via tokenGuard when a token is set. JSON content type blocks cross-site form posts;
93
+ // a browser Origin must match the host this server is served on. Absent Origin (curl, the CLI) is allowed. The audit row is written inside the delete transaction, before the rows go.
94
+ act.delete("/v1/runs/:source/:run", async (c) => {
95
+ if (!peerIsLoopback(c) || !isLoopbackHost(c.req.header("host"))) return c.json({ error: "loopback only" }, 403);
96
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 400);
97
+ const origin = c.req.header("origin");
98
+ if (origin !== undefined) {
99
+ let oh = "";
100
+ try { oh = new URL(origin).host.toLowerCase(); } catch { /* refused below */ }
101
+ if (!oh || oh !== (c.req.header("host") ?? "").toLowerCase()) return c.json({ error: "origin not allowed" }, 403);
102
+ }
103
+ const source = c.req.param("source"), run = c.req.param("run");
104
+ const r = removeRun(sqlite, source, run);
105
+ if (!r) return c.json({ error: "run not found" }, 404);
106
+ const left = (sqlite.query("select count(*) n from runs").get() as { n: number }).n;
107
+ return c.json({ ok: true, removed: r, remaining_runs: left });
108
+ });
109
+ act.get("/v1/repos", (c) => peerIsLoopback(c) ? c.json({ repos: [...new Set<string>([repoDir, ...registryRepos()])] }) : c.json({ error: "loopback only" }, 403));
110
+ act.post("/v1/start", async (c) => {
111
+ if (!local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
112
+ const text = await c.req.text();
113
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
114
+ let body: unknown;
115
+ try { body = JSON.parse(text); } catch { return c.json({ error: "invalid JSON" }, 400); }
116
+ const plan = planStart(body, [repoDir, ...registryRepos()], opts.startBin);
117
+ if (!plan.ok) return c.json({ error: plan.error }, 400);
118
+ if (inflight.has(plan.cwd)) return c.json({ error: "a run is already starting or running in this repo" }, 409);
119
+ inflight.add(plan.cwd);
120
+ const r = await (opts.spawnImpl ?? spawnStart)(plan.argv, plan.cwd, () => inflight.delete(plan.cwd));
121
+ if ("error" in r) { inflight.delete(plan.cwd); return c.json({ error: r.error }, 500); }
122
+ return c.json({ ok: true, pid: r.pid, command: plan.argv.slice(1).join(" ") });
123
+ });
69
124
  // :id is `source:run` (run ids never contain a colon)
70
125
  app.get("/v1/runs/:id", (c) => {
71
126
  const id = c.req.param("id");
@@ -0,0 +1,147 @@
1
+ // EL-FC08b (D86, FC-08, L2): integrity of an INGESTED event log, run on every rebuild of a run's projection.
2
+ // Pure: no db. The same checks `loki verify` makes on events.jsonl (loki-ts/src/engine10/verify_cmd.ts: checkEventLog, checkLogSeal),
3
+ // restated over stored envelopes. Option taken for sharing: a CP-local module plus test/server/integrity-parity.test.ts, which runs both
4
+ // implementations over the same fixtures, including signed ones (unknown kid, mismatched kid, appended run.completed), so they cannot drift.
5
+ // TAMPERED means positive evidence of forgery. A log the CP cannot check (redacted before ingest) is UNVERIFIED, never TAMPERED and never VERIFIED.
6
+ import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
7
+ import { readFileSync } from "node:fs";
8
+ import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
9
+
10
+ export interface RunIntegrity {
11
+ /** Positive evidence of forgery, loss or edit. Always shown as TAMPERED. */
12
+ tampered: boolean;
13
+ /** A VERIFIED claim backed by a signed receipt.sealed and a sealing line, with a log that could be checked. False means unattested (shown UNVERIFIED). */
14
+ attested: boolean;
15
+ /** The sealing line's signature was verified against a configured public key. False: shape and hash chain only. */
16
+ sig_checked: boolean;
17
+ reasons: string[];
18
+ }
19
+ export type KeyResolver = ((kid: string) => KeyObject | undefined) & { configured?: boolean; fingerprint?: string };
20
+ export interface IntegrityOpts {
21
+ pubkeyFor?: KeyResolver;
22
+ /** At least one key is configured: an unknown kid is then TAMPERED, as in the engine's checkLogSeal. Defaults to pubkeyFor.configured. */
23
+ keysConfigured?: boolean;
24
+ }
25
+
26
+ const SHA = /^[0-9a-f]{64}$/;
27
+ const SEAL_LINE = "log.sealed";
28
+ const REDACTED = /\[REDACTED(?::[A-Z_]+)?\]/;
29
+ export const REDACTED_NOTE = "log redacted before ingest; seal not checkable";
30
+ export const UNCHECKED_SIG = "VERIFIED (signature not checked)";
31
+ export const UNATTESTED_SUFFIX = " (unattested)";
32
+ /** Outcomes that claim success: each needs a signed seal, exactly like VERIFIED. */
33
+ export const SUCCESS_VERDICTS: ReadonlySet<string> = new Set(["VERIFIED", "ALREADY_SATISFIED"]);
34
+ const normVerdict = (v: string | null | undefined): string | null => (typeof v === "string" ? v.trim().toUpperCase() : null);
35
+
36
+ /** Same derivation as loki-ts/src/engine10/stages/seal.ts kidOf (parity-tested). */
37
+ export const kidOf = (pub: KeyObject): string => createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${pub.export({ format: "jwk" }).x}"}`).digest("base64url");
38
+
39
+ /** Public keys from LOKI_CP_RECEIPT_PUBKEYS (colon-separated PEM files, like `loki verify` retired keys), by kid. Unreadable files are skipped. */
40
+ export function pubkeysFromEnv(env: Record<string, string | undefined> = process.env): KeyResolver {
41
+ const keys = (env["LOKI_CP_RECEIPT_PUBKEYS"] ?? "").split(":").map((f) => f.trim()).filter(Boolean).flatMap((f) => {
42
+ try { return [createPublicKey(readFileSync(f))]; } catch { return []; }
43
+ });
44
+ const fn: KeyResolver = (kid) => keys.find((k) => kidOf(k) === kid);
45
+ fn.configured = keys.length > 0;
46
+ fn.fingerprint = createHash("sha256").update(keys.map(kidOf).sort().join(",")).digest("hex").slice(0, 16); // identifies the key set a row was judged under
47
+ return fn;
48
+ }
49
+
50
+ // The engine writes JSON.stringify(envelope) in this key order, so a stored envelope re-serialises to the bytes it was hashed as.
51
+ const line = (e: EventEnvelope): string => JSON.stringify({ v: e.v, seq: e.seq, ts: e.ts, run: e.run, type: e.type, stage: e.stage, data: e.data });
52
+ const verdictOf = (e: EventEnvelope | undefined): string | null => (typeof e?.data["verdict"] === "string" ? e.data["verdict"].trim().toUpperCase() : null);
53
+
54
+ /** Events up to and including the sealing line (the authenticated prefix); everything when the run has none. Lines after it are unauthenticated. */
55
+ export function sealedPrefix<T extends { type: string }>(evs: readonly T[]): T[] {
56
+ const i = evs.findIndex((e) => e.type === SEAL_LINE);
57
+ return i < 0 ? [...evs] : evs.slice(0, i + 1);
58
+ }
59
+
60
+ export function verifyRunIntegrity(evs: readonly EventEnvelope[], opts: IntegrityOpts = {}): RunIntegrity {
61
+ const bad: string[] = [], notes: string[] = [];
62
+ const doneAt = evs.findIndex((e) => e.type === "run.completed");
63
+ const sealedAt = evs.map((e, i) => (e.type === "receipt.sealed" ? i : -1)).filter((i) => i >= 0);
64
+ const finalised = doneAt >= 0 || sealedAt.length > 0;
65
+ const lines = evs.map(line);
66
+ const keysConfigured = opts.keysConfigured ?? opts.pubkeyFor?.configured ?? false;
67
+ let sigChecked = false;
68
+
69
+ if (finalised) { // a live run may still be missing a batch; once it claims an end, every seq must be present
70
+ const gap = evs.findIndex((e, i) => e.seq !== i);
71
+ if (gap >= 0) bad.push(`event log has a seq gap, duplicate or missing start at position ${gap} (seq ${evs[gap]!.seq})`);
72
+ }
73
+ if (evs.some((e) => e.type === "tamper.detected")) bad.push("the supervisor detected events.jsonl being modified during the run");
74
+ const firstSeal = evs.findIndex((e) => e.type === SEAL_LINE);
75
+ if (firstSeal >= 0 && evs.slice(firstSeal + 1).some((e) => e.type === "run.completed" || e.type === "receipt.sealed" || typeof e.data["verdict"] === "string")) bad.push("a verdict-bearing event after log.sealed is unauthenticated");
76
+ if (evs.filter((e) => e.type === "run.completed").length > 1) bad.push("more than one run.completed (a verdict appended after the seal is unauthenticated)");
77
+
78
+ // Hash problems are tamper evidence unless a redaction placeholder explains the changed bytes.
79
+ const hashBad: string[] = [];
80
+ const redacted = REDACTED.test(JSON.stringify(doneAt >= 0 ? evs.slice(0, doneAt + 2).map((e) => e.data) : evs.map((e) => e.data)));
81
+
82
+ for (const i of sealedAt) {
83
+ const d = evs[i]!.data;
84
+ if (typeof d["receipt_sha256"] !== "string" || !SHA.test(d["receipt_sha256"])) hashBad.push(`receipt.sealed at seq ${evs[i]!.seq} has no valid receipt_sha256`);
85
+ const bound = d["events_sha256"]; // optional on the event: the engine keeps it in receipt.json, which the CP never sees
86
+ if (typeof bound === "string") {
87
+ const h = createHash("sha256");
88
+ let ok = false;
89
+ for (const l of lines) { if (h.copy().digest("hex") === bound) { ok = true; break; } h.update(l + "\n"); }
90
+ if (!ok && h.digest("hex") === bound) ok = true;
91
+ if (!ok) hashBad.push("receipt.sealed events_sha256 does not match any prefix of the ingested log");
92
+ }
93
+ }
94
+
95
+ const sealLines = evs.map((e, i) => (e.type === SEAL_LINE ? i : -1)).filter((i) => i >= 0);
96
+ if (sealLines.some((i) => i !== doneAt + 1)) bad.push(`${SEAL_LINE} is not the line right after run.completed`);
97
+ const ls = doneAt >= 0 && evs[doneAt + 1]?.type === SEAL_LINE ? evs[doneAt + 1]!.data : null;
98
+ const signedSeals = sealedAt.map((i) => evs[i]!.data).filter((d) => d["signed"] === true);
99
+ const signedReceipt = signedSeals.length > 0;
100
+ if (ls) {
101
+ const sha = createHash("sha256").update(lines.slice(0, doneAt + 1).join("\n") + "\n").digest("hex");
102
+ const sig = typeof ls["sig"] === "string" ? Buffer.from(ls["sig"], "base64url") : null;
103
+ const kid = ls["kid"];
104
+ if (ls["tampered"] !== false) bad.push(`${SEAL_LINE} line records a tamper or is malformed`);
105
+ else if (typeof kid !== "string" || !sig || sig.length !== 64) hashBad.push(`${SEAL_LINE} line is malformed or records a tamper`);
106
+ else {
107
+ if (signedSeals.some((d) => typeof d["kid"] === "string" && d["kid"] !== kid)) hashBad.push(`${SEAL_LINE} kid differs from the receipt kid`);
108
+ if (ls["events_sha256"] !== sha) hashBad.push(`${SEAL_LINE} events_sha256 does not match the ingested log`);
109
+ else {
110
+ const pub = opts.pubkeyFor?.(kid);
111
+ if (pub) { sigChecked = true; if (!verify(null, Buffer.from(`${sha}:false`), pub, sig)) bad.push(`${SEAL_LINE} signature does not verify`); }
112
+ else if (keysConfigured) bad.push(`${SEAL_LINE} kid ${kid} has no configured public key`);
113
+ }
114
+ }
115
+ } else if (signedReceipt && doneAt >= 0) bad.push(`signed receipt but no ${SEAL_LINE} line after run.completed`);
116
+
117
+ let unverifiable = false;
118
+ if (hashBad.length > 0) {
119
+ if (redacted) { unverifiable = true; sigChecked = false; notes.push(REDACTED_NOTE); } else bad.push(...hashBad);
120
+ }
121
+
122
+ // A VERIFIED claim (first run.completed only) needs a receipt sealed before it, for the same verdict.
123
+ let claimsVerified = false;
124
+ const claimed = doneAt >= 0 ? verdictOf(evs[doneAt]) : null;
125
+ if (claimed !== null && SUCCESS_VERDICTS.has(claimed)) {
126
+ claimsVerified = true;
127
+ const before = sealedAt.filter((i) => i < doneAt);
128
+ if (before.length === 0) bad.push(`run.completed claims ${claimed} but no receipt.sealed precedes it`);
129
+ else if (!before.some((i) => verdictOf(evs[i]) === claimed)) bad.push(`run.completed claims ${claimed} but the sealed receipt does not`);
130
+ }
131
+
132
+ const tampered = bad.length > 0;
133
+ const attested = !tampered && !unverifiable && (!claimsVerified || (signedReceipt && ls !== null));
134
+ return { tampered, attested, sig_checked: sigChecked && !tampered, reasons: [...bad, ...notes] };
135
+ }
136
+
137
+ /** The one display verdict (FC-08). TAMPERED when the log failed integrity. When it is not attested, a success claim shows UNVERIFIED and any other verdict carries an
138
+ * "(unattested)" marker. An attested success whose seal no key checked shows "<verdict> (signature not checked)"; plain VERIFIED only for a signature-checked run.
139
+ * sig_checked undefined (older captures) reads as checked. */
140
+ export function effectiveVerdict(r: { verdict: string | null; tampered: boolean; attested?: boolean; sig_checked?: boolean }): string | null {
141
+ if (r.tampered) return "TAMPERED";
142
+ const v = normVerdict(r.verdict);
143
+ if (v === null) return r.verdict;
144
+ if (r.attested === false) return SUCCESS_VERDICTS.has(v) ? "UNVERIFIED" : `${r.verdict}${UNATTESTED_SUFFIX}`;
145
+ if (SUCCESS_VERDICTS.has(v)) return r.sig_checked === false ? `${v} (signature not checked)` : v;
146
+ return r.verdict;
147
+ }
@@ -4,6 +4,7 @@ import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
4
4
  import type { Db } from "../db/migrate.ts";
5
5
  import { events, runs } from "../db/schema.ts";
6
6
  import { blockedQuestion } from "./answer.ts";
7
+ import { effectiveVerdict, pubkeysFromEnv, sealedPrefix, SUCCESS_VERDICTS, verifyRunIntegrity } from "./integrity.ts";
7
8
 
8
9
  const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
9
10
 
@@ -16,8 +17,11 @@ export function loadEvents(db: Db, sourceId: string, runId: string): EventEnvelo
16
17
  export function rebuildRun(db: Db, sourceId: string, runId: string): void {
17
18
  const evs = loadEvents(db, sourceId, runId);
18
19
  if (evs.length === 0) return;
19
- const f = fold(evs);
20
- const pc = partialCost(evs, f.run.tampered);
20
+ const f = fold(sealedPrefix(evs)); // the verdict comes from the authenticated prefix; lines after the sealing line are unauthenticated
21
+ const keys = pubkeysFromEnv();
22
+ const integ = verifyRunIntegrity(evs, { pubkeyFor: keys }); // EL-FC08b: tampered is the supervisor flag OR any ingest-side integrity failure
23
+ const tampered = f.run.tampered || integ.tampered;
24
+ const pc = partialCost(evs, tampered);
21
25
  const sd = f.run.started?.data ?? {};
22
26
  const done = f.run.completed?.data ?? {};
23
27
  const pr = evs.find((e) => e.type === "pr.opened")?.data;
@@ -31,12 +35,15 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
31
35
  costUsd: f.cost.usd, partialUsd: pc.usd, measuredSessions: pc.measured, totalSessions: pc.total,
32
36
  inputTokens: f.cost.inputTokens, outputTokens: f.cost.outputTokens,
33
37
  wallS: typeof done.wall_s === "number" ? done.wall_s : null,
34
- lastSeq: f.lastSeq, lastEventAt: evs[evs.length - 1]?.ts ?? null, tampered: Number(f.run.tampered),
38
+ lastSeq: f.lastSeq, lastEventAt: evs[evs.length - 1]?.ts ?? null, tampered: Number(tampered),
39
+ attested: Number(integ.attested), sigChecked: Number(integ.sig_checked), integrityKeyFp: keys.fingerprint ?? "", integrityReasons: JSON.stringify(integ.reasons),
35
40
  };
36
41
  // conflict is owned by ingest, so it is left out of the update set and survives a rebuild
37
42
  db.insert(runs).values(row).onConflictDoUpdate({ target: [runs.sourceId, runs.runId], set: row }).run();
38
43
  }
39
44
 
45
+ const reasonsOf = (j: string | null): string[] => { try { const x = JSON.parse(j ?? "[]"); return Array.isArray(x) ? x.filter((s): s is string => typeof s === "string") : []; } catch { return []; } };
46
+
40
47
  const parseRun = (r: typeof runs.$inferSelect) => ({
41
48
  source_id: r.sourceId, run_id: r.runId, origin_repo: r.originRepo, issue_ref: r.issueRef, task_source: r.taskSource,
42
49
  group_id: r.groupId, unit_id: r.unitId, provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
@@ -44,6 +51,8 @@ const parseRun = (r: typeof runs.$inferSelect) => ({
44
51
  cost_usd: r.costUsd, partial_usd: r.partialUsd, measured_sessions: r.measuredSessions, total_sessions: r.totalSessions,
45
52
  input_tokens: r.inputTokens, output_tokens: r.outputTokens, wall_s: r.wallS, last_seq: r.lastSeq,
46
53
  last_event_at: r.lastEventAt, tampered: r.tampered === 1, conflict: r.conflict === 1,
54
+ attested: r.attested === 1, sig_checked: r.sigChecked === 1, integrity_reasons: reasonsOf(r.integrityReasons),
55
+ effective_verdict: effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }),
47
56
  status: r.endedAt ? "completed" : "running", elapsed_s: elapsedS(r),
48
57
  });
49
58
  // A run with no run.completed yet is running: add its live fields (stage, files) from the stored events.
@@ -75,13 +84,46 @@ const elapsedS = (r: typeof runs.$inferSelect): number | null => {
75
84
  return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
76
85
  };
77
86
 
87
+ /** FC-08: filters match the DISPLAY verdict. Plain VERIFIED (and ALREADY_SATISFIED) lists only attested, signature-checked runs; "<verdict> (signature not checked)" the attested
88
+ * rest; UNVERIFIED every non-tampered run that is not attested. */
89
+ const SIG_SUFFIX = " (signature not checked)";
90
+ const verdictFilter = (v: string | undefined) => {
91
+ if (!v) return undefined;
92
+ if (v === "TAMPERED") return eq(runs.tampered, 1);
93
+ if (v === "UNVERIFIED") return and(eq(runs.tampered, 0), sql`${runs.verdict} is not null`, sql`coalesce(${runs.attested}, 0) != 1`);
94
+ if (SUCCESS_VERDICTS.has(v)) return and(eq(runs.verdict, v), eq(runs.tampered, 0), eq(runs.attested, 1), eq(runs.sigChecked, 1));
95
+ if (v.endsWith(SIG_SUFFIX) && SUCCESS_VERDICTS.has(v.slice(0, -SIG_SUFFIX.length))) return and(eq(runs.verdict, v.slice(0, -SIG_SUFFIX.length)), eq(runs.tampered, 0), eq(runs.attested, 1), sql`coalesce(${runs.sigChecked}, 0) != 1`);
96
+ return and(eq(runs.verdict, v), eq(runs.tampered, 0), eq(runs.attested, 1));
97
+ };
98
+
99
+ const RECOMPUTE_CHUNK = 200;
100
+ /** Boot-time recompute. Rows are re-judged when their integrity was never evaluated (attested IS NULL) or was evaluated under a different key set (stored fingerprint differs).
101
+ * Chunked, one transaction per chunk. A row whose events are gone is marked unattested with a reason and the current fingerprint, so it is not retried on every boot. */
102
+ export function recomputeLegacy(db: Db): number {
103
+ const fp = pubkeysFromEnv().fingerprint ?? "";
104
+ let n = 0;
105
+ for (;;) {
106
+ const rows = db.select({ s: runs.sourceId, r: runs.runId }).from(runs).where(sql`${runs.attested} is null or ${runs.integrityKeyFp} is not ${fp}`).limit(RECOMPUTE_CHUNK).all();
107
+ if (rows.length === 0) return n;
108
+ db.transaction((tx) => {
109
+ const t = tx as unknown as Db;
110
+ for (const x of rows) {
111
+ if (loadEvents(t, x.s, x.r).length === 0) {
112
+ t.update(runs).set({ attested: 0, sigChecked: 0, integrityKeyFp: fp, integrityReasons: JSON.stringify(["stored events are missing; integrity cannot be evaluated"]) }).where(and(eq(runs.sourceId, x.s), eq(runs.runId, x.r))).run();
113
+ } else rebuildRun(t, x.s, x.r);
114
+ }
115
+ });
116
+ n += rows.length;
117
+ }
118
+ }
119
+
78
120
  export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; group_id?: string; limit?: number; cursor?: string }
79
121
 
80
122
  export function listRuns(db: Db, q: ListQuery) {
81
123
  const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
82
124
  const offset = Math.max(Number.parseInt(q.cursor ?? "0", 10) || 0, 0); // ponytail: cursor is an opaque offset, keyset when write rate makes pages shift
83
125
  const where = and(
84
- q.verdict ? eq(runs.verdict, q.verdict) : undefined,
126
+ verdictFilter(q.verdict),
85
127
  q.repo ? eq(runs.originRepo, q.repo) : undefined,
86
128
  q.group_id ? eq(runs.groupId, q.group_id) : undefined,
87
129
  q.since ? gte(runs.startedAt, q.since) : undefined,
@@ -1,9 +1,10 @@
1
1
  // C2 CP-DEFAULT: find a running local Control Plane (docs/v10/CONTROL-PLANE.md section 6). Reads
2
2
  // ~/.loki/control/instance.json; returns its url only when the pid is alive and /health answers service=loki-control
3
3
  // within 300 ms. Never starts a server, never throws. LOKI_CONTROL=0 disables discovery.
4
- import { readFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { join } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync, realpathSync } from "node:fs";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { basename, dirname, join, resolve } from "node:path";
7
8
 
8
9
  export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
9
10
 
@@ -27,3 +28,31 @@ export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts
27
28
  return h.service === "loki-control" ? url : null;
28
29
  } catch { return null; }
29
30
  }
31
+
32
+ // P0 guard: auto-discovery must never ship a throwaway or fixture repo to a developer's live Control Plane. An explicit
33
+ // LOKI_CONTROL_URL is an operator decision and is never filtered. LOKI_CONTROL_ALLOW_TMP=1 lets a sandboxed harness
34
+ // (its own HOME and its own server, repos under the temp dir) exercise discovery on purpose.
35
+ const FIXTURE_REPO = /(^|[/:])acme\/widget(\.git)?\/?$/i;
36
+ const FIXED_TMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
37
+
38
+ const real = (p: string): string => { try { return realpathSync(p); } catch { return resolve(p); } };
39
+ const under = (p: string, root: string): boolean => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
40
+
41
+ export function isThrowawayPath(repoDir: string, tmpRoots: string[] = [real(tmpdir()), ...FIXED_TMP_ROOTS]): boolean {
42
+ const p = real(repoDir), raw = resolve(repoDir);
43
+ return tmpRoots.some((r) => under(p, r) || under(raw, r) || under(p, real(r)));
44
+ }
45
+
46
+ /** A reason when a discovered (non-explicit) Control Plane must not receive this repo's events, else null. */
47
+ export function discoveryRefusal(repoDir: string, originUrl: string | null, env: NodeJS.ProcessEnv = process.env, tmpRoots?: string[]): string | null {
48
+ if (env.LOKI_CONTROL_ALLOW_TMP === "1") return null;
49
+ if (isThrowawayPath(repoDir, tmpRoots)) return "repo is under the OS temp dir";
50
+ if (originUrl && FIXTURE_REPO.test(originUrl.trim())) return "origin is the fixture repo acme/widget";
51
+ if (FIXTURE_REPO.test(`${basename(dirname(repoDir))}/${basename(repoDir)}`)) return "repo name is the fixture acme/widget";
52
+ return null;
53
+ }
54
+
55
+ /** The repo's origin url (env is threaded so the spawn never inherits ambient state), or null. */
56
+ export function gitOrigin(repoDir: string, env: NodeJS.ProcessEnv): string | null {
57
+ try { return execFileSync("git", ["-C", repoDir, "config", "--get", "remote.origin.url"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env }).trim() || null; } catch { return null; }
58
+ }
@@ -93,3 +93,18 @@ export async function shipRun(o: ShipOpts): Promise<ShipResult> {
93
93
 
94
94
  export const shipEnabled = (env: NodeJS.ProcessEnv): string | null => env.LOKI_CONTROL_URL || null;
95
95
  export const hasSpool = (runDir: string): boolean => existsSync(join(runDir, "events.jsonl"));
96
+
97
+ /** Fire-and-forget ship loop: an unref'd timer with backoff; never throws. */
98
+ export function startShipLoop(o: Omit<ShipOpts, "timeoutMs">): void {
99
+ let failures = 0, nextAt = 0, busy = false;
100
+ const tick = async (): Promise<void> => {
101
+ if (busy || Date.now() < nextAt) return;
102
+ busy = true;
103
+ try {
104
+ const r = await shipRun({ ...o, timeoutMs: 5000 });
105
+ failures = r.ok ? 0 : failures + 1;
106
+ nextAt = r.ok ? 0 : Date.now() + backoffMs(failures);
107
+ } catch { failures++; } finally { busy = false; }
108
+ };
109
+ setInterval(() => void tick(), 300).unref();
110
+ }
@@ -0,0 +1,66 @@
1
+ // CP-INGEST: zero-homework ingest. On Control Plane start, backfill the current repo plus every repo in the project
2
+ // registry (~/.loki/dashboard/projects.json), then poll each repo's .loki/runs and ship appended events.jsonl lines
3
+ // through the same shipRun path as the live hook (idempotent: ship.json holds the acked seq, the server dedupes).
4
+ import { readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { backfill } from "./backfill.ts";
8
+ import { discoveryRefusal } from "./discover.ts";
9
+ import { shipRun, sourceId } from "./ship.ts";
10
+
11
+ /** Repo dirs from the dashboard project registry; never throws, a missing or corrupt registry means none. */
12
+ export function registryRepos(env: NodeJS.ProcessEnv): string[] {
13
+ try {
14
+ const reg = JSON.parse(readFileSync(join(env.HOME || homedir(), ".loki", "dashboard", "projects.json"), "utf8")) as { projects?: Record<string, { path?: unknown }> };
15
+ return Object.values(reg.projects ?? {}).map((p) => p.path).filter((p): p is string => typeof p === "string" && p.startsWith("/"));
16
+ } catch { return []; }
17
+ }
18
+
19
+ export interface WatchOpts {
20
+ repoDir: string;
21
+ url: string;
22
+ env?: NodeJS.ProcessEnv;
23
+ intervalMs?: number;
24
+ fetchImpl?: typeof fetch;
25
+ }
26
+ export interface Watcher { stop: () => void; tick: () => Promise<number> }
27
+
28
+ const sig = (f: string): string => { try { const s = statSync(f); return `${s.size}:${s.mtimeMs}`; } catch { return ""; } };
29
+
30
+ /** Backfill every repo, then watch. Resolves once the backfill is done; the watcher keeps polling until stop(). */
31
+ export async function ingestAndWatch(o: WatchOpts): Promise<Watcher> {
32
+ const env: NodeJS.ProcessEnv = { ...(o.env ?? process.env), LOKI_CONTROL_URL: o.url };
33
+ // P0: registry entries are third-party-registered paths; skip temp and fixture repos (the explicit repoDir is always kept).
34
+ const repos = [...new Set([o.repoDir, ...registryRepos(env).filter((r) => discoveryRefusal(r, null, env) === null)])];
35
+ const seen = new Map<string, string>(); // events.jsonl path -> size:mtime last shipped OK
36
+ for (const repoDir of repos) {
37
+ try { await backfill({ repoDir, env, attempts: 1, fetchImpl: o.fetchImpl }); } catch { /* one bad repo must not block the rest */ }
38
+ }
39
+ let busy = false;
40
+ const tick = async (): Promise<number> => {
41
+ if (busy) return 0;
42
+ busy = true;
43
+ let sent = 0;
44
+ try {
45
+ for (const repoDir of repos) {
46
+ const root = join(repoDir, ".loki", "runs");
47
+ let ids: string[] = [];
48
+ try { ids = readdirSync(root); } catch { continue; }
49
+ const source = sourceId(repoDir);
50
+ for (const id of ids) {
51
+ const runDir = join(root, id), f = join(runDir, "events.jsonl"), s = sig(f);
52
+ if (s === "" || seen.get(f) === s) continue;
53
+ try {
54
+ const r = await shipRun({ runDir, url: o.url, source, token: env.LOKI_CONTROL_TOKEN, fetchImpl: o.fetchImpl });
55
+ sent += r.sent;
56
+ if (r.ok) seen.set(f, s);
57
+ } catch { /* retry next tick */ }
58
+ }
59
+ }
60
+ } finally { busy = false; }
61
+ return sent;
62
+ };
63
+ const timer = setInterval(() => { void tick(); }, o.intervalMs ?? 1000);
64
+ (timer as { unref?: () => void }).unref?.();
65
+ return { stop: () => clearInterval(timer), tick };
66
+ }
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.min-h-11{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-0{min-width:0px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-red-500\/50{border-color:#ef444480}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-300{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.bg-amber-500\/15{background-color:#f59e0b26}.bg-emerald-500\/15{background-color:#10b98126}.bg-red-500\/15{background-color:#ef444426}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-600\/20{background-color:#dc262633}.bg-sky-500\/15{background-color:#0ea5e926}.bg-sky-600{--tw-bg-opacity: 1;background-color:rgb(2 132 199 / var(--tw-bg-opacity, 1))}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-500\/15{background-color:#64748b26}.bg-violet-500\/15{background-color:#8b5cf626}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-sky-600{--tw-text-opacity: 1;color:rgb(2 132 199 / var(--tw-text-opacity, 1))}.text-sky-700{--tw-text-opacity: 1;color:rgb(3 105 161 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-violet-700{--tw-text-opacity: 1;color:rgb(109 40 217 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-amber-500\/30{--tw-ring-color: rgb(245 158 11 / .3)}.ring-emerald-500\/30{--tw-ring-color: rgb(16 185 129 / .3)}.ring-red-500\/30{--tw-ring-color: rgb(239 68 68 / .3)}.ring-red-600\/50{--tw-ring-color: rgb(220 38 38 / .5)}.ring-sky-500\/30{--tw-ring-color: rgb(14 165 233 / .3)}.ring-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:underline:hover{text-decoration-line:underline}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-300:is(.dark *){--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is(.dark *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:flex{display:flex}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-end{align-items:flex-end}}@media(min-width:768px){.md\:mb-6{margin-bottom:1.5rem}.md\:ml-0{margin-left:0}.md\:mt-6{margin-top:1.5rem}.md\:block{display:block}.md\:table-cell{display:table-cell}.md\:min-h-0{min-height:0px}.md\:w-48{width:12rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:border-b-0{border-bottom-width:0px}.md\:border-r{border-right-width:1px}.md\:p-4{padding:1rem}.md\:p-6{padding:1.5rem}.md\:px-2{padding-left:.5rem;padding-right:.5rem}.md\:px-3{padding-left:.75rem;padding-right:.75rem}.md\:py-0{padding-top:0;padding-bottom:0}.md\:py-1{padding-top:.25rem;padding-bottom:.25rem}}