leanlab 0.2.1__py3-none-any.whl
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.
- leanlab/__init__.py +1 -0
- leanlab/cli.py +315 -0
- leanlab/core/__init__.py +1 -0
- leanlab/core/agents/__init__.py +10 -0
- leanlab/core/agents/claude.py +38 -0
- leanlab/core/agents/port.py +49 -0
- leanlab/core/agents/protocol.py +64 -0
- leanlab/core/coding/__init__.py +1 -0
- leanlab/core/coding/board.py +335 -0
- leanlab/core/coding/board_dist/assets/index-BBCkNArL.css +1 -0
- leanlab/core/coding/board_dist/assets/index-CNGMDAuO.js +40 -0
- leanlab/core/coding/board_dist/index.html +13 -0
- leanlab/core/coding/engineer.py +304 -0
- leanlab/core/coding/gate.py +63 -0
- leanlab/core/coding/personas.py +23 -0
- leanlab/core/coding/playbook.py +47 -0
- leanlab/core/coding/spec.py +232 -0
- leanlab/core/doctor.py +220 -0
- leanlab/core/init.py +219 -0
- leanlab/core/loop.py +374 -0
- leanlab/core/monitor.py +553 -0
- leanlab/templates/agents/CLAUDE.md +52 -0
- leanlab/templates/agents/critic.md +38 -0
- leanlab/templates/agents/director.md +37 -0
- leanlab/templates/agents/engineer.md +12 -0
- leanlab/templates/agents/reviewer.md +34 -0
- leanlab/templates/agents/techlead.md +7 -0
- leanlab/templates/skill/SKILL.md +99 -0
- leanlab-0.2.1.dist-info/METADATA +273 -0
- leanlab-0.2.1.dist-info/RECORD +33 -0
- leanlab-0.2.1.dist-info/WHEEL +4 -0
- leanlab-0.2.1.dist-info/entry_points.txt +2 -0
- leanlab-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""The coding board — a dashboard for a coding lab's tasks, status, traces, and PLAYBOOK.
|
|
2
|
+
|
|
3
|
+
Overview (`/`) lists task cards from `.leanlab/worktrees/*`, `.leanlab/coding-results.jsonl`,
|
|
4
|
+
and `.leanlab/PLAYBOOK.md`. A task detail (`/?task=<slug>`) shows the build TIMELINE (from the
|
|
5
|
+
structured event log written by `build`) and the live agent CHAT (parsed from the worktree's
|
|
6
|
+
Claude transcripts). `coding_state` / `task_detail` / the renderers are pure and testable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import webbrowser
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import parse_qs, urlparse
|
|
20
|
+
|
|
21
|
+
from .playbook import read_playbook
|
|
22
|
+
|
|
23
|
+
_STATUS = {"merged": "#3fb950", "failed": "#f85149", "spec'd": "#d29922", "building": "#58a6ff"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- structured event log (the timeline) ------------------------------------
|
|
27
|
+
def _events_path(repo, slug):
|
|
28
|
+
return Path(repo) / ".leanlab" / "events" / f"{slug}.jsonl"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_event(repo, slug, rec):
|
|
32
|
+
"""Append one build event for a task (used by spec/build to feed the timeline)."""
|
|
33
|
+
p = _events_path(repo, slug)
|
|
34
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
with p.open("a") as f:
|
|
36
|
+
f.write(json.dumps({**rec, "ts": datetime.now(timezone.utc).isoformat()}) + "\n")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_events(repo, slug):
|
|
40
|
+
p = _events_path(repo, slug)
|
|
41
|
+
if not p.exists():
|
|
42
|
+
return []
|
|
43
|
+
out = []
|
|
44
|
+
for line in p.read_text().splitlines():
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if line:
|
|
47
|
+
try:
|
|
48
|
+
out.append(json.loads(line))
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- agent chat (transcripts) ----------------------------------------------
|
|
55
|
+
def _transcript_dir(repo, slug):
|
|
56
|
+
"""The Claude transcript dir for a task's worktree, or None."""
|
|
57
|
+
wt = Path(repo) / ".leanlab" / "worktrees" / slug
|
|
58
|
+
base = Path.home() / ".claude" / "projects"
|
|
59
|
+
if not base.is_dir():
|
|
60
|
+
return None
|
|
61
|
+
d = base / str(wt.resolve()).replace("/", "-")
|
|
62
|
+
if d.is_dir():
|
|
63
|
+
return d
|
|
64
|
+
matches = sorted(base.glob(f"*worktrees-{slug}")) # specific tail — avoids other projects
|
|
65
|
+
return matches[-1] if matches else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_SESSIONS_CACHE = {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parsed_sessions(d):
|
|
72
|
+
"""[(path, events)] for every session in `d`, oldest first — parsed ONCE and cached by
|
|
73
|
+
the dir's (name, mtime) signature. The SSE loop polls task detail every second; without
|
|
74
|
+
this each poll would re-parse every transcript file."""
|
|
75
|
+
sessions = sorted(d.glob("*.jsonl"))
|
|
76
|
+
sig = tuple((p.name, p.stat().st_mtime) for p in sessions)
|
|
77
|
+
cached = _SESSIONS_CACHE.get(str(d))
|
|
78
|
+
if cached and cached[0] == sig:
|
|
79
|
+
return cached[1]
|
|
80
|
+
from ..monitor import parse_session # reuse the metric dashboard's parser
|
|
81
|
+
parsed = [(p, parse_session(p)[1])
|
|
82
|
+
for p in sorted(sessions, key=lambda p: p.stat().st_mtime)]
|
|
83
|
+
_SESSIONS_CACHE[str(d)] = (sig, parsed)
|
|
84
|
+
return parsed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _task_transcript_events(repo, slug):
|
|
88
|
+
"""Every agent session's events for a task, oldest first — one session per claude call,
|
|
89
|
+
so all build attempts and reviews show, not just the latest. A `divider` event marks
|
|
90
|
+
each session boundary."""
|
|
91
|
+
d = _transcript_dir(repo, slug)
|
|
92
|
+
if not d:
|
|
93
|
+
return []
|
|
94
|
+
runs = [events for _p, events in _parsed_sessions(d) if events]
|
|
95
|
+
out = []
|
|
96
|
+
for i, events in enumerate(runs, 1):
|
|
97
|
+
tok = sum((e.get("in_tok") or 0) + (e.get("out_tok") or 0) for e in events)
|
|
98
|
+
out.append({"kind": "divider", "text": f"session {i}/{len(runs)}", "tokens": tok})
|
|
99
|
+
out.extend(events)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _task_usage(repo, slug):
|
|
104
|
+
"""Total tokens + cost across ALL agent sessions for a task."""
|
|
105
|
+
d = _transcript_dir(repo, slug)
|
|
106
|
+
if not d:
|
|
107
|
+
return {"tokens": 0, "cost": 0.0}
|
|
108
|
+
tokens = cost = 0
|
|
109
|
+
for _p, events in _parsed_sessions(d):
|
|
110
|
+
for e in events:
|
|
111
|
+
tokens += (e.get("in_tok") or 0) + (e.get("out_tok") or 0)
|
|
112
|
+
cost += e.get("cost") or 0
|
|
113
|
+
return {"tokens": tokens, "cost": round(cost, 4)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- state builders ---------------------------------------------------------
|
|
117
|
+
def _results(repo):
|
|
118
|
+
p = Path(repo) / ".leanlab" / "coding-results.jsonl"
|
|
119
|
+
latest = {}
|
|
120
|
+
if p.exists():
|
|
121
|
+
for line in p.read_text().splitlines():
|
|
122
|
+
line = line.strip()
|
|
123
|
+
if line:
|
|
124
|
+
try:
|
|
125
|
+
r = json.loads(line)
|
|
126
|
+
latest[r["slug"]] = r
|
|
127
|
+
except (ValueError, KeyError):
|
|
128
|
+
pass
|
|
129
|
+
return latest
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _spec_summary(d):
|
|
133
|
+
spec = (d / "SPEC.md").read_text() if (d / "SPEC.md").exists() else ""
|
|
134
|
+
lines = [ln.strip() for ln in spec.splitlines() if ln.strip()]
|
|
135
|
+
return next((ln for ln in lines if not ln.startswith("#")), lines[0] if lines else "(no spec)")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _task_slugs(repo):
|
|
139
|
+
"""Every task we know about — live worktrees PLUS finished ones recorded in results/events.
|
|
140
|
+
|
|
141
|
+
A task's worktree is removed once it merges and is cleaned, so the worktree dir alone
|
|
142
|
+
forgets completed work. Union the durable records so the board keeps the full history.
|
|
143
|
+
"""
|
|
144
|
+
repo = Path(repo)
|
|
145
|
+
wtroot = repo / ".leanlab" / "worktrees"
|
|
146
|
+
live = [d.name for d in sorted(wtroot.iterdir()) if d.is_dir()] if wtroot.is_dir() else []
|
|
147
|
+
durable = [*_results(repo)] + [f.stem for f in sorted((repo / ".leanlab" / "events").glob("*.jsonl"))]
|
|
148
|
+
seen = set(live)
|
|
149
|
+
archived = []
|
|
150
|
+
for slug in durable:
|
|
151
|
+
if slug not in seen:
|
|
152
|
+
seen.add(slug)
|
|
153
|
+
archived.append(slug)
|
|
154
|
+
return live, archived
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _task_status(repo, slug, by_slug=None):
|
|
158
|
+
"""A task's status: the result row wins; otherwise inferred from the event log."""
|
|
159
|
+
by_slug = _results(repo) if by_slug is None else by_slug
|
|
160
|
+
r = by_slug.get(slug)
|
|
161
|
+
if r:
|
|
162
|
+
return "merged" if r.get("merged") else "failed"
|
|
163
|
+
evs = read_events(repo, slug)
|
|
164
|
+
if any(e.get("event") == "merged" and e.get("merged") for e in evs):
|
|
165
|
+
return "merged"
|
|
166
|
+
if any(e.get("event") == "gaveup" for e in evs):
|
|
167
|
+
return "failed"
|
|
168
|
+
return "spec'd"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def coding_state(repo) -> dict:
|
|
172
|
+
repo = Path(repo)
|
|
173
|
+
by_slug = _results(repo)
|
|
174
|
+
wtroot = repo / ".leanlab" / "worktrees"
|
|
175
|
+
live, archived = _task_slugs(repo)
|
|
176
|
+
tasks = []
|
|
177
|
+
for slug in live + archived:
|
|
178
|
+
d = wtroot / slug
|
|
179
|
+
is_live = d.is_dir()
|
|
180
|
+
r = by_slug.get(slug)
|
|
181
|
+
status = _task_status(repo, slug, by_slug)
|
|
182
|
+
attempts = (r or {}).get("attempts")
|
|
183
|
+
if attempts is None: # recover the count from the event log
|
|
184
|
+
n = sum(1 for e in read_events(repo, slug) if e.get("event") == "attempt")
|
|
185
|
+
attempts = n or None
|
|
186
|
+
if is_live:
|
|
187
|
+
spec = _spec_summary(d)
|
|
188
|
+
else:
|
|
189
|
+
spec = "merged — worktree cleaned" if status == "merged" else "worktree cleaned"
|
|
190
|
+
u = _task_usage(repo, slug)
|
|
191
|
+
tasks.append({"slug": slug, "status": status, "branch": f"leanlab/{slug}",
|
|
192
|
+
"attempts": attempts, "spec": spec, "archived": not is_live,
|
|
193
|
+
"tokens": u["tokens"], "cost": u["cost"]})
|
|
194
|
+
merged = sum(t["status"] == "merged" for t in tasks)
|
|
195
|
+
failed = sum(t["status"] == "failed" for t in tasks)
|
|
196
|
+
decided = merged + failed
|
|
197
|
+
totals = {"tasks": len(tasks), "merged": merged, "failed": failed,
|
|
198
|
+
"open": sum(t["status"] == "spec'd" for t in tasks),
|
|
199
|
+
"tokens": sum(t["tokens"] for t in tasks),
|
|
200
|
+
"cost": round(sum(t["cost"] for t in tasks), 4),
|
|
201
|
+
"success": round(100 * merged / decided) if decided else None}
|
|
202
|
+
return {"tasks": tasks, "playbook": read_playbook(repo), "totals": totals}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def task_detail(repo, slug) -> dict:
|
|
206
|
+
repo = Path(repo)
|
|
207
|
+
wt = repo / ".leanlab" / "worktrees" / slug
|
|
208
|
+
usage = _task_usage(repo, slug) # tokens + cost across all the task's sessions
|
|
209
|
+
return {"slug": slug, "exists": wt.is_dir(), "status": _task_status(repo, slug),
|
|
210
|
+
"spec": _spec_summary(wt) if wt.is_dir() else "",
|
|
211
|
+
"timeline": read_events(repo, slug), "stream": _task_transcript_events(repo, slug),
|
|
212
|
+
"cost": usage["cost"], "tokens": usage["tokens"]}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --- live SPA (SSE) ---------------------------------------------------------
|
|
217
|
+
def overview_state(repo) -> dict:
|
|
218
|
+
return {"lab": Path(repo).resolve().name, **coding_state(repo)}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_DIST = Path(__file__).resolve().parent / "board_dist" # built React app (see frontend/)
|
|
222
|
+
|
|
223
|
+
_NOT_BUILT_HTML = (
|
|
224
|
+
"<!doctype html><meta charset='utf-8'>"
|
|
225
|
+
"<body style='font:14px system-ui;background:#0a0a0a;color:#e6edf3;padding:40px'>"
|
|
226
|
+
"<h2>Board UI not built</h2>"
|
|
227
|
+
"<p>Run <code>cd frontend && npm install && npm run build</code> to compile it.</p>"
|
|
228
|
+
"</body>"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _asset(rel):
|
|
233
|
+
"""Resolve a request path to a file inside the built board, rejecting path traversal."""
|
|
234
|
+
rel = (rel or "").lstrip("/") or "index.html"
|
|
235
|
+
f = (_DIST / rel).resolve()
|
|
236
|
+
if _DIST.resolve() not in f.parents:
|
|
237
|
+
return None
|
|
238
|
+
return f if f.is_file() else None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _QuietServer(ThreadingHTTPServer):
|
|
242
|
+
daemon_threads = True
|
|
243
|
+
|
|
244
|
+
def handle_error(self, request, client_address):
|
|
245
|
+
exc = sys.exc_info()[1]
|
|
246
|
+
if not isinstance(exc, (ConnectionResetError, BrokenPipeError, TimeoutError)):
|
|
247
|
+
super().handle_error(request, client_address)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def serve_board(repo, port=8766, open_browser=True):
|
|
251
|
+
repo = Path(repo).resolve()
|
|
252
|
+
|
|
253
|
+
class Handler(BaseHTTPRequestHandler):
|
|
254
|
+
protocol_version = "HTTP/1.1"
|
|
255
|
+
|
|
256
|
+
def handle(self):
|
|
257
|
+
try:
|
|
258
|
+
super().handle()
|
|
259
|
+
except (ConnectionResetError, BrokenPipeError, TimeoutError):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
def _send(self, body, ctype="application/json"):
|
|
263
|
+
data = body.encode() if isinstance(body, str) else body
|
|
264
|
+
self.send_response(200)
|
|
265
|
+
self.send_header("Content-Type", ctype)
|
|
266
|
+
self.send_header("Content-Length", str(len(data)))
|
|
267
|
+
self.end_headers()
|
|
268
|
+
self.wfile.write(data)
|
|
269
|
+
|
|
270
|
+
def _sse(self, event, payload):
|
|
271
|
+
self.wfile.write(f"event: {event}\ndata: {payload}\n\n".encode())
|
|
272
|
+
self.wfile.flush()
|
|
273
|
+
|
|
274
|
+
def stream(self, slug):
|
|
275
|
+
self.send_response(200)
|
|
276
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
277
|
+
self.send_header("Cache-Control", "no-cache")
|
|
278
|
+
self.end_headers()
|
|
279
|
+
last_state = last_task = None
|
|
280
|
+
last_ping = 0.0
|
|
281
|
+
try:
|
|
282
|
+
while True:
|
|
283
|
+
st = json.dumps(overview_state(repo))
|
|
284
|
+
if st != last_state:
|
|
285
|
+
self._sse("state", st)
|
|
286
|
+
last_state = st
|
|
287
|
+
if slug:
|
|
288
|
+
td = json.dumps(task_detail(repo, slug))
|
|
289
|
+
if td != last_task:
|
|
290
|
+
self._sse("task", td)
|
|
291
|
+
last_task = td
|
|
292
|
+
if time.time() - last_ping > 15:
|
|
293
|
+
self.wfile.write(b": ping\n\n")
|
|
294
|
+
self.wfile.flush()
|
|
295
|
+
last_ping = time.time()
|
|
296
|
+
time.sleep(1)
|
|
297
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
def do_GET(self):
|
|
301
|
+
route = urlparse(self.path)
|
|
302
|
+
q = parse_qs(route.query)
|
|
303
|
+
try:
|
|
304
|
+
if route.path == "/api/stream":
|
|
305
|
+
self.stream(q.get("task", [""])[0])
|
|
306
|
+
return
|
|
307
|
+
if route.path == "/api/state":
|
|
308
|
+
self._send(json.dumps(overview_state(repo)))
|
|
309
|
+
return
|
|
310
|
+
if route.path == "/api/task":
|
|
311
|
+
self._send(json.dumps(task_detail(repo, q.get("task", [""])[0])))
|
|
312
|
+
return
|
|
313
|
+
# static: the built React app. Unknown paths fall back to index.html (SPA).
|
|
314
|
+
f = _asset(route.path) or _asset("index.html")
|
|
315
|
+
if f is None:
|
|
316
|
+
self._send(_NOT_BUILT_HTML, "text/html; charset=utf-8")
|
|
317
|
+
return
|
|
318
|
+
ctype = mimetypes.guess_type(str(f))[0] or "application/octet-stream"
|
|
319
|
+
if ctype.startswith("text/") or ctype == "image/svg+xml":
|
|
320
|
+
ctype += "; charset=utf-8"
|
|
321
|
+
self._send(f.read_bytes(), ctype)
|
|
322
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
def log_message(self, *a):
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
url = f"http://127.0.0.1:{port}"
|
|
329
|
+
print(f"leanlab coding board: {url}")
|
|
330
|
+
if open_browser:
|
|
331
|
+
webbrowser.open(url)
|
|
332
|
+
try:
|
|
333
|
+
_QuietServer(("127.0.0.1", port), Handler).serve_forever()
|
|
334
|
+
except KeyboardInterrupt:
|
|
335
|
+
print("\nstopped.")
|
|
@@ -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:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;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,Menlo,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}html,body,#root{height:100%}body{--tw-bg-opacity: 1;background-color:rgb(10 10 10 / var(--tw-bg-opacity, 1));font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;--tw-text-opacity: 1;color:rgb(230 237 243 / var(--tw-text-opacity, 1));margin:0;font-size:14px;line-height:1.5}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:6px}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3\.5{margin-bottom:.875rem}.ml-auto{margin-left:auto}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.block{display:block}.flex{display:flex}.table{display:table}.grid{display:grid}.h-4{height:1rem}.h-\[440px\]{height:440px}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[260px\]{max-height:260px}.max-h-\[300px\]{max-height:300px}.max-h-\[340px\]{max-height:340px}.min-h-0{min-height:0px}.min-h-\[32px\]{min-height:32px}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-\[1360px\]{max-width:1360px}.max-w-\[380px\]{max-width:380px}.flex-1{flex:1 1 0%}.flex-\[2\]{flex:2}.border-collapse{border-collapse:collapse}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.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-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))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-\[\#1f3a5f\]{--tw-border-opacity: 1;border-color:rgb(31 58 95 / var(--tw-border-opacity, 1))}.border-\[\#332a4a\]{--tw-border-opacity: 1;border-color:rgb(51 42 74 / var(--tw-border-opacity, 1))}.border-accent{--tw-border-opacity: 1;border-color:rgb(88 166 255 / var(--tw-border-opacity, 1))}.border-amber{--tw-border-opacity: 1;border-color:rgb(210 153 34 / var(--tw-border-opacity, 1))}.border-amber\/40{border-color:#d2992266}.border-good{--tw-border-opacity: 1;border-color:rgb(63 185 80 / var(--tw-border-opacity, 1))}.border-line{--tw-border-opacity: 1;border-color:rgb(38 38 38 / var(--tw-border-opacity, 1))}.border-purple{--tw-border-opacity: 1;border-color:rgb(163 113 247 / var(--tw-border-opacity, 1))}.bg-\[\#0e1216\]{--tw-bg-opacity: 1;background-color:rgb(14 18 22 / var(--tw-bg-opacity, 1))}.bg-\[\#0e1a2b\]{--tw-bg-opacity: 1;background-color:rgb(14 26 43 / var(--tw-bg-opacity, 1))}.bg-\[\#10243b\]{--tw-bg-opacity: 1;background-color:rgb(16 36 59 / var(--tw-bg-opacity, 1))}.bg-\[\#16121f\]{--tw-bg-opacity: 1;background-color:rgb(22 18 31 / var(--tw-bg-opacity, 1))}.bg-\[\#22262e\]{--tw-bg-opacity: 1;background-color:rgb(34 38 46 / var(--tw-bg-opacity, 1))}.bg-accent{--tw-bg-opacity: 1;background-color:rgb(88 166 255 / var(--tw-bg-opacity, 1))}.bg-amber{--tw-bg-opacity: 1;background-color:rgb(210 153 34 / var(--tw-bg-opacity, 1))}.bg-amber\/10{background-color:#d299221a}.bg-bad{--tw-bg-opacity: 1;background-color:rgb(248 81 73 / var(--tw-bg-opacity, 1))}.bg-bad\/10{background-color:#f851491a}.bg-good{--tw-bg-opacity: 1;background-color:rgb(63 185 80 / var(--tw-bg-opacity, 1))}.bg-good\/10{background-color:#3fb9501a}.bg-panel{--tw-bg-opacity: 1;background-color:rgb(20 20 20 / var(--tw-bg-opacity, 1))}.bg-panel2{--tw-bg-opacity: 1;background-color:rgb(27 27 27 / var(--tw-bg-opacity, 1))}.bg-panel2\/40{background-color:#1b1b1b66}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pl-2{padding-left:.5rem}.pl-3{padding-left:.75rem}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:ui-monospace,Menlo,monospace}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[26px\]{font-size:26px}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-snug{line-height:1.375}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-accent{--tw-text-opacity: 1;color:rgb(88 166 255 / var(--tw-text-opacity, 1))}.text-amber{--tw-text-opacity: 1;color:rgb(210 153 34 / var(--tw-text-opacity, 1))}.text-bad{--tw-text-opacity: 1;color:rgb(248 81 73 / var(--tw-text-opacity, 1))}.text-good{--tw-text-opacity: 1;color:rgb(63 185 80 / var(--tw-text-opacity, 1))}.text-ink{--tw-text-opacity: 1;color:rgb(230 237 243 / var(--tw-text-opacity, 1))}.text-muted{--tw-text-opacity: 1;color:rgb(139 148 158 / var(--tw-text-opacity, 1))}.text-purple{--tw-text-opacity: 1;color:rgb(163 113 247 / var(--tw-text-opacity, 1))}.opacity-100{opacity:1}.opacity-40{opacity:.4}.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)}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.before\:h-px:before{content:var(--tw-content);height:1px}.before\:flex-1:before{content:var(--tw-content);flex:1 1 0%}.before\:bg-line:before{content:var(--tw-content);--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.before\:content-\[\'\'\]:before{--tw-content: "";content:var(--tw-content)}.after\:h-px:after{content:var(--tw-content);height:1px}.after\:flex-1:after{content:var(--tw-content);flex:1 1 0%}.after\:bg-line:after{content:var(--tw-content);--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity, 1))}.after\:content-\[\'\'\]:after{--tw-content: "";content:var(--tw-content)}.hover\:border-accent:hover{--tw-border-opacity: 1;border-color:rgb(88 166 255 / var(--tw-border-opacity, 1))}.hover\:bg-panel2:hover{--tw-bg-opacity: 1;background-color:rgb(27 27 27 / var(--tw-bg-opacity, 1))}.hover\:text-ink:hover{--tw-text-opacity: 1;color:rgb(230 237 243 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:768px){.md\:flex-row{flex-direction:row}.md\:gap-0{gap:0px}}@media(min-width:1024px){.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}}
|