leopold-driver 0.1.0 → 0.1.2

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 (48) hide show
  1. package/README.md +19 -5
  2. package/assets/VERSION +1 -0
  3. package/assets/extensions/README.md +52 -0
  4. package/assets/extensions/gstack/extension.json +8 -0
  5. package/assets/extensions/gstack/manage.sh +68 -0
  6. package/assets/extensions/leopold/extension.json +8 -0
  7. package/assets/extensions/leopold/manage.sh +59 -0
  8. package/assets/extensions/ovmem/README.md +101 -0
  9. package/assets/extensions/ovmem/extension.json +8 -0
  10. package/assets/extensions/ovmem/install.sh +330 -0
  11. package/assets/extensions/ovmem/manage.sh +87 -0
  12. package/assets/extensions/ovmem/models.json +24 -0
  13. package/assets/extensions/ovmem/payload/RUNTIME.md +121 -0
  14. package/assets/extensions/ovmem/payload/ovmem-cleanup.py +148 -0
  15. package/assets/extensions/ovmem/payload/ovmem.py +421 -0
  16. package/assets/extensions/serena/README.md +50 -0
  17. package/assets/extensions/serena/extension.json +8 -0
  18. package/assets/extensions/serena/manage.sh +119 -0
  19. package/assets/hooks/guard-irreversible.sh +185 -0
  20. package/assets/hooks/hooks.json +20 -0
  21. package/assets/hooks/stop-continuity.sh +132 -0
  22. package/assets/install.sh +150 -0
  23. package/assets/scripts/__pycache__/leopold-watch.cpython-312.pyc +0 -0
  24. package/assets/scripts/leopold-doctor.sh +53 -0
  25. package/assets/scripts/leopold-menu.sh +132 -0
  26. package/assets/scripts/leopold-update-check.sh +23 -0
  27. package/assets/scripts/leopold-update.sh +13 -0
  28. package/assets/scripts/leopold-watch.py +585 -0
  29. package/assets/scripts/record-demo.sh +61 -0
  30. package/assets/scripts/test-guard.sh +76 -0
  31. package/assets/scripts/test-hooks.sh +121 -0
  32. package/assets/settings.template.json +23 -0
  33. package/assets/skills/leopold-brief/SKILL.md +121 -0
  34. package/assets/skills/leopold-doctor/SKILL.md +23 -0
  35. package/assets/skills/leopold-run/SKILL.md +171 -0
  36. package/assets/skills/leopold-status/SKILL.md +34 -0
  37. package/assets/skills/leopold-stop/SKILL.md +36 -0
  38. package/assets/skills/leopold-update/SKILL.md +27 -0
  39. package/assets/skills/leopold-watch/SKILL.md +48 -0
  40. package/assets/templates/CHARTER.md +32 -0
  41. package/assets/templates/DECISIONS.md +15 -0
  42. package/assets/templates/GUARDRAILS.md +38 -0
  43. package/assets/templates/MISSION.md +22 -0
  44. package/assets/templates/PLAN.md +9 -0
  45. package/dist/guard.js +82 -23
  46. package/dist/harness.js +71 -0
  47. package/dist/index.js +53 -23
  48. package/package.json +18 -6
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env python3
2
+ """Leopold watch — a local, zero-dependency live dashboard for an autonomous run.
3
+
4
+ It reads the run's own files in `.leopold/` (state.json, PLAN.md, DECISIONS.md,
5
+ events.jsonl) AND the Claude Code session transcript (for real token/cost data), and
6
+ serves a dashboard on 127.0.0.1 with live (SSE) updates. Read-only except one action:
7
+ a Stop button that touches `.leopold/STOP` — the same kill switch `/leopold-stop` uses.
8
+
9
+ Cost is parsed from the transcript JSONL (each assistant message carries `usage` +
10
+ `model`); the dashboard finds it via the run state's `transcript_path` or by the cwd's
11
+ project slug under ~/.claude/projects/. Cost is an ESTIMATE from a built-in price map.
12
+
13
+ No dependencies (Python 3.8+ stdlib). Nothing leaves the machine; it binds to loopback
14
+ and uses no web fonts. Usage:
15
+
16
+ python3 leopold-watch.py [--project DIR] [--port 4179] [--host 127.0.0.1]
17
+ """
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import time
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+
25
+ LEO = "" # set in main(): the project's .leopold dir
26
+ PROJECT = "" # set in main(): the project root (abspath), used to find the transcript
27
+
28
+
29
+ # --------------------------------------------------------------------------- readers
30
+ def _read(name):
31
+ try:
32
+ with open(os.path.join(LEO, name), "r", encoding="utf-8", errors="replace") as f:
33
+ return f.read()
34
+ except OSError:
35
+ return ""
36
+
37
+
38
+ def read_state():
39
+ raw = _read("state.json")
40
+ if not raw.strip():
41
+ return {}
42
+ try:
43
+ return json.loads(raw)
44
+ except json.JSONDecodeError:
45
+ return {"_invalid": True}
46
+
47
+
48
+ def read_plan():
49
+ items, done, opened = [], 0, 0
50
+ for line in _read("PLAN.md").splitlines():
51
+ s = line.strip()
52
+ if s.startswith("- [ ]"):
53
+ opened += 1
54
+ items.append({"done": False, "text": s[5:].strip()})
55
+ elif s.lower().startswith("- [x]"):
56
+ done += 1
57
+ items.append({"done": True, "text": s[5:].strip()})
58
+ return {"open": opened, "done": done, "total": opened + done, "items": items}
59
+
60
+
61
+ def read_decisions(limit=8):
62
+ text = _read("DECISIONS.md")
63
+ blocks, cur = [], []
64
+ for line in text.splitlines():
65
+ if line.strip() == "---":
66
+ if any(x.strip() for x in cur):
67
+ blocks.append("\n".join(cur).strip())
68
+ cur = []
69
+ else:
70
+ cur.append(line)
71
+ if any(x.strip() for x in cur):
72
+ blocks.append("\n".join(cur).strip())
73
+ out = [b.replace("**", "") for b in blocks
74
+ if ("Fork:" in b or "Decision:" in b or "Decisão:" in b)]
75
+ return out[-limit:][::-1]
76
+
77
+
78
+ def read_events(limit=60):
79
+ path = os.path.join(LEO, "events.jsonl")
80
+ try:
81
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
82
+ lines = f.readlines()
83
+ except OSError:
84
+ return []
85
+ out = []
86
+ for line in lines[-limit:]:
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+ try:
91
+ out.append(json.loads(line))
92
+ except json.JSONDecodeError:
93
+ continue
94
+ return out[::-1] # newest first
95
+
96
+
97
+ # --------------------------------------------------------------------------- cost / session
98
+ # Estimated prices, USD per million tokens. cache-write defaults to 1.25x input, cache-read
99
+ # to 0.1x input (Anthropic's standard cache pricing). Matched by model family substring.
100
+ # Override per model/family via a JSON file in $LEOPOLD_PRICES or .leopold/prices.json, e.g.
101
+ # {"opus": {"in": 15, "out": 75, "cache_write": 18.75, "cache_read": 1.5},
102
+ # "claude-my-model-1": {"in": 2, "out": 8}}
103
+ # (cache_write/cache_read optional; merged over these defaults; restart watch to apply.)
104
+ PRICES = {
105
+ "opus": {"in": 15.0, "out": 75.0},
106
+ "sonnet": {"in": 3.0, "out": 15.0},
107
+ "haiku": {"in": 1.0, "out": 5.0},
108
+ }
109
+ _DEFAULT_PRICE = {"in": 3.0, "out": 15.0}
110
+ _PRICES_LOADED = None
111
+
112
+
113
+ def _with_cache(p):
114
+ q = {"in": float(p["in"]), "out": float(p["out"])}
115
+ q["cache_write"] = float(p.get("cache_write", q["in"] * 1.25))
116
+ q["cache_read"] = float(p.get("cache_read", q["in"] * 0.1))
117
+ return q
118
+
119
+
120
+ def _load_prices():
121
+ global _PRICES_LOADED
122
+ if _PRICES_LOADED is not None:
123
+ return _PRICES_LOADED
124
+ prices = {k: _with_cache(v) for k, v in PRICES.items()}
125
+ for src in (os.environ.get("LEOPOLD_PRICES"), os.path.join(LEO, "prices.json")):
126
+ if not src or not os.path.isfile(src):
127
+ continue
128
+ try:
129
+ with open(src, "r", encoding="utf-8") as f:
130
+ override = json.load(f)
131
+ except (OSError, ValueError):
132
+ continue
133
+ if isinstance(override, dict):
134
+ for k, v in override.items():
135
+ if isinstance(v, dict) and "in" in v and "out" in v:
136
+ try:
137
+ prices[str(k).lower()] = _with_cache(v)
138
+ except (TypeError, ValueError):
139
+ pass
140
+ _PRICES_LOADED = prices
141
+ return prices
142
+
143
+
144
+ def _price(model):
145
+ prices = _load_prices()
146
+ m = (model or "").lower()
147
+ if m in prices: # exact-model override wins
148
+ return prices[m]
149
+ for fam, p in prices.items(): # then family substring (opus / sonnet / haiku / custom)
150
+ if fam in m:
151
+ return p
152
+ return _with_cache(_DEFAULT_PRICE)
153
+
154
+
155
+ def _projects_dir():
156
+ base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
157
+ return os.path.join(base, "projects")
158
+
159
+
160
+ def find_transcript():
161
+ # 1) explicit path recorded by the Stop hook (the run's actual session).
162
+ tp = read_state().get("transcript_path")
163
+ if tp and os.path.isfile(tp):
164
+ return tp
165
+ # 2) auto-discover: Claude Code stores sessions under <config>/projects/<slug>/,
166
+ # where <slug> is the project path with non-alphanumerics replaced by '-'.
167
+ slug = re.sub(r"[^a-zA-Z0-9]", "-", PROJECT or os.getcwd())
168
+ d = os.path.join(_projects_dir(), slug)
169
+ try:
170
+ files = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".jsonl")]
171
+ except OSError:
172
+ return None
173
+ return max(files, key=os.path.getmtime) if files else None
174
+
175
+
176
+ def _iso_delta(a, b):
177
+ if not a or not b:
178
+ return 0
179
+ try:
180
+ from datetime import datetime
181
+ def p(s):
182
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
183
+ return max(0, int((p(b) - p(a)).total_seconds()))
184
+ except Exception:
185
+ return 0
186
+
187
+
188
+ _COST_CACHE = {} # path -> (mtime, size, result) — avoid re-parsing on every SSE tick
189
+
190
+
191
+ def _parse_cost(tp):
192
+ tot = {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}
193
+ usd = main_usd = sub_usd = 0.0
194
+ msgs = sub_msgs = 0
195
+ models = {}
196
+ t_first = t_last = None
197
+ session = ""
198
+ try:
199
+ f = open(tp, "r", encoding="utf-8", errors="replace")
200
+ except OSError:
201
+ return {"available": False}
202
+ with f:
203
+ for line in f:
204
+ if '"usage"' not in line: # cheap pre-filter: only assistant turns carry cost
205
+ continue
206
+ try:
207
+ o = json.loads(line)
208
+ except (json.JSONDecodeError, ValueError):
209
+ continue
210
+ if o.get("type") != "assistant":
211
+ continue
212
+ m = o.get("message") or {}
213
+ u = m.get("usage") or {}
214
+ if not u:
215
+ continue
216
+ ts = o.get("timestamp")
217
+ if ts:
218
+ t_first = ts if t_first is None else min(t_first, ts)
219
+ t_last = ts if t_last is None else max(t_last, ts)
220
+ if not session:
221
+ session = o.get("sessionId", "") or ""
222
+ model = m.get("model", "") or "?"
223
+ inp = int(u.get("input_tokens", 0) or 0)
224
+ out = int(u.get("output_tokens", 0) or 0)
225
+ cw = int(u.get("cache_creation_input_tokens", 0) or 0)
226
+ cr = int(u.get("cache_read_input_tokens", 0) or 0)
227
+ pr = _price(model)
228
+ c = (inp * pr["in"] + out * pr["out"] + cw * pr["cache_write"] + cr * pr["cache_read"]) / 1e6
229
+ tot["input"] += inp; tot["output"] += out
230
+ tot["cache_write"] += cw; tot["cache_read"] += cr
231
+ usd += c; msgs += 1
232
+ if o.get("isSidechain"):
233
+ sub_usd += c; sub_msgs += 1
234
+ else:
235
+ main_usd += c
236
+ mm = models.setdefault(model, {"usd": 0.0, "msgs": 0})
237
+ mm["usd"] += c; mm["msgs"] += 1
238
+ tot["total"] = sum(tot.values())
239
+ cacheable = tot["input"] + tot["cache_write"] + tot["cache_read"]
240
+ hit = round(tot["cache_read"] / cacheable * 100) if cacheable else 0
241
+ model_list = sorted(
242
+ ({"model": k, "usd": round(v["usd"], 4), "msgs": v["msgs"]} for k, v in models.items()),
243
+ key=lambda x: -x["usd"],
244
+ )
245
+ return {
246
+ "available": True,
247
+ "usd": round(usd, 4),
248
+ "tokens": tot,
249
+ "cache_hit_pct": hit,
250
+ "messages": msgs,
251
+ "sub_msgs": sub_msgs,
252
+ "main_usd": round(main_usd, 4),
253
+ "sub_usd": round(sub_usd, 4),
254
+ "models": model_list[:4],
255
+ "duration_s": _iso_delta(t_first, t_last),
256
+ "session": session,
257
+ }
258
+
259
+
260
+ def read_cost():
261
+ tp = find_transcript()
262
+ if not tp:
263
+ return {"available": False}
264
+ try:
265
+ st = os.stat(tp)
266
+ except OSError:
267
+ return {"available": False}
268
+ cached = _COST_CACHE.get(tp)
269
+ if cached and cached[0] == st.st_mtime and cached[1] == st.st_size:
270
+ return cached[2]
271
+ result = _parse_cost(tp)
272
+ _COST_CACHE[tp] = (st.st_mtime, st.st_size, result)
273
+ return result
274
+
275
+
276
+ # --------------------------------------------------------------------------- snapshot
277
+ def _num(state, key, default):
278
+ v = state.get(key, default)
279
+ try:
280
+ return float(v)
281
+ except (TypeError, ValueError):
282
+ return default
283
+
284
+
285
+ def snapshot():
286
+ st = read_state()
287
+ plan = read_plan()
288
+ meters = [
289
+ {"label": "context", "val": round(_num(st, "context_mb", 0), 1),
290
+ "max": _num(st, "max_context_mb", 5), "unit": "MB"},
291
+ {"label": "iterations", "val": int(_num(st, "iteration", 0)),
292
+ "max": int(_num(st, "max_iterations", 50)), "unit": ""},
293
+ {"label": "subagents", "val": int(_num(st, "subagents_spawned", 0)),
294
+ "max": int(_num(st, "max_subagents", 8)), "unit": ""},
295
+ {"label": "forks", "val": int(_num(st, "forks_spawned", 0)),
296
+ "max": int(_num(st, "max_forks", 0)), "unit": ""},
297
+ {"label": "failures", "val": int(_num(st, "consecutive_failures", 0)),
298
+ "max": int(_num(st, "max_failures", 3)), "unit": ""},
299
+ ]
300
+ return {
301
+ "present": bool(st) and not st.get("_invalid"),
302
+ "invalid": bool(st.get("_invalid")),
303
+ "active": st.get("active") is True,
304
+ "stopped_reason": st.get("stopped_reason", ""),
305
+ "stop_requested": os.path.exists(os.path.join(LEO, "STOP")),
306
+ "session_id": st.get("session_id", ""),
307
+ "plan": plan,
308
+ "meters": meters,
309
+ "cost": read_cost(),
310
+ "events": read_events(),
311
+ "decisions": read_decisions(),
312
+ "ts": int(time.time()),
313
+ }
314
+
315
+
316
+ # --------------------------------------------------------------------------- page
317
+ # Design system: warm cream (light) / near-black (dark), monochrome with semantic green/red
318
+ # + severity tones; Geist / Geist Mono type stack (system fallback, no web fonts -> offline).
319
+ PAGE = r"""<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8">
320
+ <meta name="viewport" content="width=device-width,initial-scale=1">
321
+ <title>Leopold watch</title>
322
+ <style>
323
+ :root{
324
+ --bg:#efe8da;--fg:#141414;--card:#f6f2e9;--secondary:#e3dccc;--muted-fg:#616161;
325
+ --border:#d7cfbe;--ring:#333;--destructive:#ae1f1f;--dfg:#f7f3ea;--success:#248052;
326
+ --hairline:rgba(20,20,20,.15);--radius:12px;
327
+ --sev-crit:#b91c1c;--sev-high:#c2410c;--sev-med:#b45309;--sev-low:#0369a1;--warnbar:#b45309;
328
+ --sans:"Geist","Neue Montreal","General Sans","Inter",ui-sans-serif,system-ui,sans-serif;
329
+ --mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
330
+ }
331
+ html.dark{
332
+ --bg:#0a0a0a;--fg:#d9d9d9;--card:#0a0a0a;--secondary:#1a1a1a;--muted-fg:#808080;
333
+ --border:#262626;--ring:#d9d9d9;--destructive:#7d2020;--dfg:#fafafa;--success:#45c98a;
334
+ --hairline:rgba(217,217,217,.15);
335
+ --sev-crit:#fecaca;--sev-high:#fed7aa;--sev-med:#fde68a;--sev-low:#bae6fd;--warnbar:#d29922;
336
+ }
337
+ *{box-sizing:border-box}
338
+ html,body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);
339
+ -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
340
+ .wrap{max-width:1000px;margin:0 auto;padding:26px 20px 48px}
341
+ .head{display:flex;align-items:center;gap:11px;margin-bottom:18px}
342
+ .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted-fg)}
343
+ .title{font-weight:600;font-size:15px;letter-spacing:-.01em}
344
+ .proj{font-family:var(--mono);font-size:11px;color:var(--muted-fg);letter-spacing:.04em}
345
+ .grow{flex:1}.sub{color:var(--muted-fg)}.tnum{font-variant-numeric:tabular-nums}
346
+ .tgl{background:transparent;border:1px solid var(--border);color:var(--muted-fg);border-radius:9999px;
347
+ height:28px;padding:0 13px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer}
348
+ .tgl:hover{color:var(--fg);border-color:var(--muted-fg)}
349
+ .card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;
350
+ opacity:0;animation:up .6s cubic-bezier(.22,1,.36,1) forwards}
351
+ .card:nth-child(2){animation-delay:.04s}.card:nth-child(3){animation-delay:.08s}
352
+ .card:nth-child(4){animation-delay:.12s}.card:nth-child(5){animation-delay:.16s}
353
+ @keyframes up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
354
+ .sectitle{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted-fg);margin-bottom:12px}
355
+ .row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
356
+ .pill{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--border);border-radius:9999px;padding:5px 13px;
357
+ font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-fg)}
358
+ .pill .dot{width:8px;height:8px;border-radius:9999px;background:currentColor}
359
+ .pill.on{border-color:rgba(36,128,82,.5);color:var(--success)}
360
+ .pill.bad{border-color:rgba(174,31,31,.5);color:var(--destructive)}
361
+ .pulse{animation:pulse 2s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
362
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:34px;padding:0 16px;font-family:var(--sans);
363
+ font-weight:500;font-size:13px;border-radius:6px;border:1px solid rgba(0,0,0,.25);cursor:pointer;
364
+ background:var(--destructive);color:var(--dfg);box-shadow:0 3px 0 0 rgba(0,0,0,.35);
365
+ transition:transform .1s ease-out,box-shadow .1s ease-out}
366
+ .btn:hover{transform:translateY(-1px);box-shadow:0 4px 0 0 rgba(0,0,0,.35)}
367
+ .btn:active{transform:translateY(3px);box-shadow:inset 0 3px 6px rgba(0,0,0,.35)}
368
+ .btn:disabled{opacity:.35;cursor:default;transform:none;box-shadow:0 3px 0 0 rgba(0,0,0,.2)}
369
+ .cost{margin-top:16px}
370
+ .cost .big{font-family:var(--mono);font-size:32px;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1}
371
+ .cost .est{font-family:var(--mono);font-size:10px;color:var(--muted-fg);letter-spacing:.12em;text-transform:uppercase;margin-left:9px}
372
+ .cost .meta{font-family:var(--mono);font-size:11px;color:var(--muted-fg);margin-top:6px}
373
+ .toks{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:12px;margin-top:16px}
374
+ .tok .k{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-fg)}
375
+ .tok .v{font-family:var(--mono);font-size:16px;font-variant-numeric:tabular-nums;margin-top:3px}
376
+ .mrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
377
+ .mchip{font-family:var(--mono);font-size:10px;letter-spacing:.04em;border:1px solid var(--border);border-radius:9999px;padding:3px 10px;color:var(--muted-fg)}
378
+ .meters{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}
379
+ .meter .top{display:flex;justify-content:space-between;align-items:baseline}
380
+ .meter .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-fg)}
381
+ .meter .val{font-family:var(--mono);font-size:12px;font-variant-numeric:tabular-nums}
382
+ .bar{height:5px;background:var(--secondary);border-radius:9999px;margin-top:7px;overflow:hidden}
383
+ .bar>i{display:block;height:100%;background:var(--success);transition:width .3s}
384
+ .bar.warn>i{background:var(--warnbar)}.bar.full>i{background:var(--destructive)}
385
+ .sev{display:inline-flex;align-items:center;border-radius:9999px;border:1px solid;padding:2px 7px;font-family:var(--mono);
386
+ font-size:10px;letter-spacing:.05em;text-transform:uppercase;line-height:1.4;white-space:nowrap}
387
+ .sev-crit{color:var(--sev-crit);background:rgba(239,68,68,.10);border-color:rgba(239,68,68,.4)}
388
+ .sev-high{color:var(--sev-high);background:rgba(249,115,22,.10);border-color:rgba(249,115,22,.4)}
389
+ .sev-med{color:var(--sev-med);background:rgba(245,158,11,.10);border-color:rgba(245,158,11,.4)}
390
+ .sev-low{color:var(--sev-low);background:rgba(14,165,233,.10);border-color:rgba(14,165,233,.4)}
391
+ .sev-info{color:var(--muted-fg);background:transparent;border-color:var(--border)}
392
+ .feed{max-height:340px;overflow:auto}
393
+ .ev{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--hairline)}
394
+ .ev:last-child{border-bottom:0}
395
+ .ev .t{font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--muted-fg);white-space:nowrap}
396
+ .ev .dt{font-family:var(--mono);font-size:12px;color:var(--muted-fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
397
+ .plan ul{list-style:none;padding:0;margin:0;max-height:200px;overflow:auto}
398
+ .plan li{font-family:var(--mono);font-size:12px;padding:3px 0}
399
+ .plan li.d{color:var(--muted-fg);text-decoration:line-through}.plan .mk{color:var(--muted-fg)}
400
+ .dec{background:var(--secondary);border:1px solid var(--hairline);border-radius:8px;padding:10px 12px;margin-bottom:8px;
401
+ font-family:var(--mono);font-size:12px;white-space:pre-wrap;line-height:1.5}
402
+ .empty{color:var(--muted-fg);padding:6px 0;font-size:12px}
403
+ ::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}
404
+ ::-webkit-scrollbar-thumb{background:var(--hairline);border:2px solid var(--bg);border-radius:9999px}
405
+ ::selection{background:var(--fg);color:var(--bg)}
406
+ </style>
407
+ <script>try{document.documentElement.className=localStorage.getItem("leo-theme")||"dark"}catch(e){}</script>
408
+ </head><body><div class="wrap">
409
+ <div class="head">
410
+ <span class="eyebrow">Leopold</span><span class="title">watch</span>
411
+ <span class="proj" id="proj"></span><span class="grow"></span>
412
+ <button class="tgl" id="tgl">theme</button>
413
+ </div>
414
+ <div class="card">
415
+ <div class="row">
416
+ <span class="pill" id="status"><span class="dot" id="dot"></span><span id="stext">—</span></span>
417
+ <span class="sub tnum" id="planline" style="font-family:var(--mono);font-size:11px"></span>
418
+ <span class="grow"></span>
419
+ <button class="btn" id="stop" disabled>Stop run</button>
420
+ </div>
421
+ <div class="cost" id="cost"></div>
422
+ </div>
423
+ <div class="card"><div class="sectitle">Budgets</div><div class="meters" id="meters"></div></div>
424
+ <div class="card"><div class="sectitle">Live events</div><div class="feed" id="feed"></div></div>
425
+ <div class="card"><div class="sectitle">Plan</div><div id="plan" class="plan"></div></div>
426
+ <div class="card"><div class="sectitle">Decisions · newest</div><div id="decisions"></div></div>
427
+ <script>
428
+ const $=s=>document.querySelector(s);
429
+ function el(t,c,txt){const e=document.createElement(t);if(c)e.className=c;if(txt!=null)e.textContent=txt;return e;}
430
+ function hms(ts){return ts&&ts.length>=19?ts.slice(11,19):"";}
431
+ function fmtUsd(x){if(x==null)return"$0";return x>=1?("$"+x.toFixed(2)):("$"+x.toFixed(x>=0.01?3:4));}
432
+ function fmtTok(n){return n>=1e6?(n/1e6).toFixed(2)+"M":n>=1e3?(n/1e3).toFixed(1)+"k":(""+(n||0));}
433
+ function fmtDur(s){if(!s)return"0m";const h=Math.floor(s/3600),m=Math.floor(s%3600/60);return h?(h+"h"+m+"m"):(m+"m"+(m?"":(s%60+"s")));}
434
+ const SEV={guard_block:"sev-crit",state_invalid:"sev-crit",turn_start:"sev-low",stop:"sev-info",subagent_spawn:"sev-med"};
435
+ function renderCost(c){
436
+ const box=$("#cost");box.innerHTML="";
437
+ if(!c||!c.available){box.append(el("div","meta","waiting for session data… (cost shows once the run has a turn)"));return;}
438
+ const hero=el("div");hero.append(el("span","big",fmtUsd(c.usd)),el("span","est","est · "+(c.models[0]?c.models[0].model.replace("claude-",""):"")));
439
+ box.append(hero);
440
+ const t=c.tokens;
441
+ box.append(el("div","meta",c.messages+" turns · "+fmtTok(t.total)+" tokens · cache "+c.cache_hit_pct+"% · "+fmtDur(c.duration_s)));
442
+ const grid=el("div","toks");
443
+ [["input",t.input],["output",t.output],["cache write",t.cache_write],["cache read",t.cache_read]].forEach(p=>{
444
+ const d=el("div","tok");d.append(el("div","k",p[0]),el("div","v",fmtTok(p[1])));grid.append(d);
445
+ });
446
+ box.append(grid);
447
+ if(c.models&&c.models.length){const mr=el("div","mrow");
448
+ c.models.forEach(m=>mr.append(el("span","mchip",m.model.replace("claude-","")+" · "+fmtUsd(m.usd))));box.append(mr);}
449
+ if(c.sub_msgs)box.append(el("div","meta","main "+fmtUsd(c.main_usd)+" · subagents "+fmtUsd(c.sub_usd)+" ("+c.sub_msgs+" msgs)"));
450
+ }
451
+ function render(s){
452
+ $("#proj").textContent=s.session_id?("· "+s.session_id):"";
453
+ const pill=$("#status"),dot=$("#dot"),tx=$("#stext");
454
+ pill.className="pill";dot.classList.remove("pulse");
455
+ if(!s.present){tx.textContent="no active run";}
456
+ else if(s.invalid){pill.className="pill bad";tx.textContent="state invalid";}
457
+ else if(s.active){pill.className="pill on";dot.classList.add("pulse");tx.textContent="run active";}
458
+ else{tx.textContent="stopped"+(s.stopped_reason?(" · "+s.stopped_reason):"");}
459
+ $("#planline").textContent=s.plan.total?("plan "+s.plan.done+"/"+s.plan.total):"";
460
+ const stop=$("#stop");stop.disabled=!s.active;stop.textContent=s.stop_requested?"stop requested…":"Stop run";
461
+ renderCost(s.cost);
462
+ const m=$("#meters");m.innerHTML="";
463
+ s.meters.forEach(x=>{
464
+ const pct=x.max>0?Math.min(100,Math.round(x.val/x.max*100)):(x.val>0?100:0);
465
+ const d=el("div","meter"),top=el("div","top");
466
+ top.append(el("span","lbl",x.label),el("span","val tnum",x.val+x.unit+" / "+x.max+x.unit));
467
+ const bar=el("div","bar"+(pct>=100?" full":pct>=75?" warn":"")),i=el("i");i.style.width=pct+"%";bar.append(i);
468
+ d.append(top,bar);m.append(d);
469
+ });
470
+ const f=$("#feed");f.innerHTML="";
471
+ if(!s.events.length)f.append(el("div","empty","no events yet"));
472
+ s.events.forEach(e=>{
473
+ const r=el("div","ev");r.append(el("span","t",hms(e.ts)));
474
+ let sev=SEV[e.event]||"sev-info";
475
+ if(e.event==="subagent_spawn"&&e.fork)sev="sev-high";
476
+ r.append(el("span","sev "+sev,(e.event||"?").replace(/_/g," ")));
477
+ let d="";
478
+ if(e.event==="turn_start")d="iter "+e.iteration+" · open "+e.open_items+(e.no_progress?(" · stuck "+e.no_progress):"");
479
+ else if(e.event==="guard_block")d=e.tool||"";
480
+ else if(e.event==="subagent_spawn")d=(e.prompt_kb||0)+"KB"+(e.fork?" · FORK":"")+" · #"+(e.total||"");
481
+ else if(e.event==="stop")d="reason: "+(e.reason||"");
482
+ else if(e.event==="state_invalid")d=e.reason||"";
483
+ r.append(el("span","dt",d));f.append(r);
484
+ });
485
+ const p=$("#plan");p.innerHTML="";
486
+ if(!s.plan.items.length)p.append(el("div","empty","no PLAN.md items"));
487
+ else{const ul=el("ul");s.plan.items.forEach(it=>{const li=el("li",it.done?"d":null);
488
+ li.append(el("span","mk",it.done?"[x] ":"[ ] "));li.append(document.createTextNode(it.text));ul.append(li);});p.append(ul);}
489
+ const dc=$("#decisions");dc.innerHTML="";
490
+ if(!s.decisions.length)dc.append(el("div","empty","none yet"));
491
+ s.decisions.forEach(b=>dc.append(el("div","dec",b)));
492
+ }
493
+ $("#stop").addEventListener("click",()=>{
494
+ if(!confirm("Stop the run at the next turn boundary? (touches .leopold/STOP)"))return;
495
+ fetch("/api/stop",{method:"POST"}).then(()=>{const b=$("#stop");b.textContent="stop requested…";b.disabled=true;});
496
+ });
497
+ $("#tgl").addEventListener("click",()=>{
498
+ const d=document.documentElement.className!=="dark";document.documentElement.className=d?"dark":"light";
499
+ try{localStorage.setItem("leo-theme",d?"dark":"light")}catch(e){}
500
+ });
501
+ fetch("/api/state").then(r=>r.json()).then(render).catch(()=>{});
502
+ const es=new EventSource("/api/events");
503
+ es.onmessage=ev=>{try{render(JSON.parse(ev.data))}catch(_){}};
504
+ </script></body></html>"""
505
+
506
+
507
+ # --------------------------------------------------------------------------- server
508
+ class Handler(BaseHTTPRequestHandler):
509
+ def log_message(self, *a): # quiet
510
+ pass
511
+
512
+ def _send(self, code, ctype, body):
513
+ self.send_response(code)
514
+ self.send_header("Content-Type", ctype)
515
+ self.send_header("Cache-Control", "no-store")
516
+ self.send_header("Content-Length", str(len(body)))
517
+ self.end_headers()
518
+ self.wfile.write(body)
519
+
520
+ def do_GET(self):
521
+ path = self.path.split("?", 1)[0]
522
+ if path == "/":
523
+ self._send(200, "text/html; charset=utf-8", PAGE.encode())
524
+ elif path == "/api/state":
525
+ self._send(200, "application/json", json.dumps(snapshot()).encode())
526
+ elif path == "/api/events":
527
+ self._sse()
528
+ else:
529
+ self._send(404, "text/plain", b"not found")
530
+
531
+ def do_POST(self):
532
+ if self.path.split("?", 1)[0] == "/api/stop":
533
+ try:
534
+ open(os.path.join(LEO, "STOP"), "a").close()
535
+ self._send(200, "application/json", b'{"ok":true}')
536
+ except OSError as e:
537
+ self._send(500, "application/json", json.dumps({"ok": False, "error": str(e)}).encode())
538
+ else:
539
+ self._send(404, "text/plain", b"not found")
540
+
541
+ def _sse(self):
542
+ self.send_response(200)
543
+ self.send_header("Content-Type", "text/event-stream")
544
+ self.send_header("Cache-Control", "no-store")
545
+ self.send_header("Connection", "keep-alive")
546
+ self.end_headers()
547
+ last, beat = None, 0
548
+ try:
549
+ while True:
550
+ data = json.dumps(snapshot())
551
+ if data != last:
552
+ self.wfile.write(b"data: " + data.encode() + b"\n\n")
553
+ self.wfile.flush()
554
+ last = data
555
+ beat += 1
556
+ if beat % 15 == 0:
557
+ self.wfile.write(b": ping\n\n")
558
+ self.wfile.flush()
559
+ time.sleep(1.0)
560
+ except (BrokenPipeError, ConnectionResetError):
561
+ return
562
+
563
+
564
+ def main():
565
+ global LEO, PROJECT
566
+ ap = argparse.ArgumentParser(description="Local live dashboard for a Leopold run.")
567
+ ap.add_argument("--project", default=os.getcwd(), help="project dir containing .leopold/ (default: cwd)")
568
+ ap.add_argument("--port", type=int, default=int(os.environ.get("LEOPOLD_WATCH_PORT", "4179")))
569
+ ap.add_argument("--host", default="127.0.0.1")
570
+ args = ap.parse_args()
571
+ PROJECT = os.path.abspath(args.project)
572
+ LEO = os.path.join(PROJECT, ".leopold")
573
+ httpd = ThreadingHTTPServer((args.host, args.port), Handler)
574
+ httpd.daemon_threads = True
575
+ url = "http://%s:%d" % (args.host, args.port)
576
+ print("Leopold watch -> %s (project: %s)" % (url, args.project))
577
+ print("Reading: %s + the session transcript · Ctrl-C to stop" % LEO)
578
+ try:
579
+ httpd.serve_forever()
580
+ except KeyboardInterrupt:
581
+ print("\nbye")
582
+
583
+
584
+ if __name__ == "__main__":
585
+ main()
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # Record the Leopold demo as a SCRIPTED WALKTHROUGH (not a live model run) and render it
3
+ # to an animated SVG for the README. Honest by design: it narrates the real flow using the
4
+ # examples/add-json-output brief as the storyline; it does not fake a Claude Code session.
5
+ #
6
+ # Needs: asciinema, and a renderer (agg OR svg-term-cli). If they are missing this prints
7
+ # install hints and exits 0 (so CI/automation never breaks on it).
8
+ #
9
+ # asciinema: https://asciinema.org/docs/installation
10
+ # agg: https://github.com/asciinema/agg (cargo install agg / brew install agg)
11
+ # svg-term: npm i -g svg-term-cli
12
+ #
13
+ # Usage: bash scripts/record-demo.sh -> assets/demo.cast + assets/demo.svg
14
+ set -euo pipefail
15
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
16
+ OUT="$ROOT/assets"; CAST="$OUT/demo.cast"; SVG="$OUT/demo.svg"
17
+
18
+ have() { command -v "$1" >/dev/null 2>&1; }
19
+ if ! have asciinema; then echo "asciinema not found — see header for install hints. Skipping."; exit 0; fi
20
+ RENDER=""
21
+ have agg && RENDER="agg"; { [ -z "$RENDER" ] && have svg-term && RENDER="svg-term"; } || true
22
+ [ -n "$RENDER" ] || { echo "no renderer (agg or svg-term) found — see header. Skipping."; exit 0; }
23
+
24
+ mkdir -p "$OUT"
25
+
26
+ # The scripted walkthrough: what the viewer sees, with pauses. Kept short (~60s).
27
+ play() {
28
+ c() { printf '\033[36m$ %s\033[0m\n' "$1"; sleep 1.1; } # prompt line
29
+ n() { printf '\033[2m%s\033[0m\n' "$1"; sleep 0.9; } # narration
30
+ g() { printf '\033[32m%s\033[0m\n' "$1"; sleep 0.7; } # good/output
31
+ clear
32
+ n "# Leopold — brief it like a teammate, it conducts Claude Code."
33
+ c "/leopold-brief"
34
+ n "A debate, not a form. It writes MISSION / CHARTER / PLAN."
35
+ g " wrote .leopold/{MISSION,CHARTER,GUARDRAILS,PLAN}.md"
36
+ sleep 0.6
37
+ c "/leopold-run"
38
+ g " state.json active=true — git is now LOCKED"
39
+ n "Turn 1: pick the next PLAN item, do it, decide forks from the CHARTER."
40
+ g " [x] add toJSON() helper"
41
+ n "Fork: pretty-print JSON or one line? Charter: 'smallest change', machine-facing."
42
+ g " DECISION -> single line (reversible). logged to DECISIONS.md"
43
+ g " [x] register --json flag [x] snapshot the default output"
44
+ sleep 0.5
45
+ n "Stop hook: work remains -> re-inject 'continue'. No 'should I continue?'."
46
+ g " [x] test --json [x] document the flag"
47
+ n "PLAN complete. It stops and reports."
48
+ g " make test: green. Staged, NOT committed."
49
+ g " Ready for you: git commit -m 'feat(cli): add --json output'"
50
+ sleep 1.2
51
+ }
52
+
53
+ echo "-> recording $CAST"
54
+ asciinema rec --overwrite -c "bash -c '$(declare -f play); play'" "$CAST"
55
+
56
+ echo "-> rendering $SVG with $RENDER"
57
+ case "$RENDER" in
58
+ agg) agg "$CAST" "$OUT/demo.gif" && echo " (agg makes a GIF: $OUT/demo.gif)";;
59
+ svg-term) svg-term --in "$CAST" --out "$SVG" --window && echo " wrote $SVG";;
60
+ esac
61
+ echo "Done. Embed it at the top of README.md."