@caddis/cli 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
  2. package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
  3. package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
  4. package/bundles/antigravity-plugin/guard_agy.py +23 -4
  5. package/bundles/antigravity-plugin/plugin.json +1 -1
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
  12. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
  13. package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
  14. package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
  15. package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
  16. package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
  17. package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
  18. package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
  19. package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
  20. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
  21. package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
  22. package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
  23. package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
  24. package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
  25. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
  26. package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
  27. package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
  28. package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
  29. package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
  30. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
  31. package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
  32. package/bundles/antigravity-plugin/session_end_agy.py +21 -2
  33. package/bundles/antigravity-plugin/skills/_registry.md +1 -0
  34. package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
  37. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
  39. package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
  40. package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
  41. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  42. package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
  43. package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
  45. package/bundles/manifest.json +3 -3
  46. package/dist/claude-deepseek.js +544 -0
  47. package/dist/claude-glm.js +544 -0
  48. package/dist/claude-oss.js +544 -0
  49. package/dist/cli.js +365 -44
  50. package/package.json +5 -2
  51. package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
@@ -0,0 +1,1323 @@
1
+ #!/usr/bin/env python3
2
+ """caddis /usage-review: local usage analysis + harness self-tuning recommendations.
3
+
4
+ Reads:
5
+ <artifact-dir>/usage-log.jsonl per-session digest (Stop hook). Tries `.caddis/`, then the older
6
+ `.claude/usage-log.jsonl`.
7
+ ~/.claude/projects/<slug>/ session transcripts (agent dispatches, context size)
8
+ claude-harness/agents/*.md agent model tiers (frontmatter)
9
+
10
+ Writes (into the repo's `.caddis/`):
11
+ <output-dir>/usage-review.html graph-first HTML dashboard (default: <artifact-dir>/reviews)
12
+ <artifact-dir>/.last-usage-review timestamp for cadence nudge (updated on each run)
13
+
14
+ Prints: markdown report to stdout.
15
+
16
+ CLI: usage_review.py [--days N] [--cwd PATH] [--no-html] [--output-dir DIR]
17
+ """
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ from collections import defaultdict
25
+ from datetime import datetime, timedelta, timezone
26
+ from pathlib import Path
27
+
28
+ # Shared artifact-dir resolution (claude-harness/scripts/claudster_config.py). usage_review ships
29
+ # from scripts/ but the helper lives with the harness, so add it to the path; the inline fallback
30
+ # keeps a standalone copy of this script working.
31
+ try:
32
+ _HARNESS_SCRIPTS = str(Path(__file__).resolve().parent.parent / "claude-harness" / "scripts")
33
+ if _HARNESS_SCRIPTS not in sys.path:
34
+ sys.path.insert(0, _HARNESS_SCRIPTS)
35
+ from claudster_config import ARTIFACT_DIRS, artifact_root
36
+ except Exception: # pragma: no cover — defensive
37
+ ARTIFACT_DIRS = (".caddis",)
38
+
39
+ def artifact_root(root):
40
+ return Path(root) / ARTIFACT_DIRS[0]
41
+
42
+ _reconfig = getattr(sys.stdout, "reconfigure", None)
43
+ if _reconfig:
44
+ try:
45
+ _reconfig(encoding="utf-8")
46
+ except Exception:
47
+ pass
48
+
49
+ # ── repo root ────────────────────────────────────────────────────────────────
50
+
51
+ def _repo_root(start: str) -> str:
52
+ """Git repo root for `start`, or `start` when not a git repo (best-effort).
53
+
54
+ The Stop hook writes the usage log to the repo root's artifact dir, so the
55
+ review anchors there too — otherwise running it from a subfolder reads an
56
+ empty/partial log. Non-git projects fall back to `start` unchanged.
57
+ """
58
+ try:
59
+ out = subprocess.run(
60
+ ["git", "-C", start, "rev-parse", "--show-toplevel"],
61
+ capture_output=True, text=True, timeout=3,
62
+ )
63
+ root = out.stdout.strip()
64
+ if out.returncode == 0 and root:
65
+ return root
66
+ except Exception:
67
+ pass
68
+ return start
69
+
70
+
71
+ # ── model helpers ──────────────────────────────────────────────────────────────
72
+
73
+ def _tier(model: str) -> str:
74
+ m = (model or "").lower()
75
+ if "fable" in m:
76
+ return "fable"
77
+ if "opus" in m:
78
+ return "opus"
79
+ if "sonnet" in m:
80
+ return "sonnet"
81
+ if "haiku" in m:
82
+ return "haiku"
83
+ return "sonnet"
84
+
85
+ TIER_COLORS = {
86
+ "fable": "#B06BC9",
87
+ "opus": "#C9736B",
88
+ "sonnet": "#E8A04C",
89
+ "haiku": "#8FA66B",
90
+ }
91
+
92
+ # ── project slug → transcript dir ─────────────────────────────────────────────
93
+
94
+ def _transcript_dir(cwd: str) -> Path | None:
95
+ """Locate ~/.claude/projects/<slug>/ for a project path."""
96
+ p = cwd.replace("\\", "/").rstrip("/")
97
+ p = re.sub(r"^([a-zA-Z]):/", lambda m: m.group(1) + "--", p)
98
+ slug = p.replace("/", "-")
99
+ base = Path.home() / ".claude" / "projects"
100
+ for candidate in [slug, slug.lower()]:
101
+ d = base / candidate
102
+ if d.is_dir():
103
+ return d
104
+ return None
105
+
106
+ # ── data loading ───────────────────────────────────────────────────────────────
107
+
108
+ def _usage_log_path(cwd: str) -> str:
109
+ """The usage log to read: `.caddis/`, then the older `.claude/`.
110
+
111
+ Returns the write-side path (under the dir the repo lives in) when none exists yet, so the
112
+ caller's `isfile` guard and the "no data" message both name the right place.
113
+ """
114
+ for name in ARTIFACT_DIRS:
115
+ cand = os.path.join(cwd, name, "usage-log.jsonl")
116
+ if os.path.isfile(cand):
117
+ return cand
118
+ legacy = os.path.join(cwd, ".claude", "usage-log.jsonl")
119
+ if os.path.isfile(legacy):
120
+ return legacy
121
+ return os.path.join(str(artifact_root(cwd)), "usage-log.jsonl")
122
+
123
+
124
+ def _load_log_window(cwd: str, start: datetime, end: datetime) -> list[dict]:
125
+ """Return one record per session with ts = first entry, metrics = last entry."""
126
+ path = _usage_log_path(cwd)
127
+ if not os.path.isfile(path):
128
+ return []
129
+ first_ts: dict[str, datetime] = {}
130
+ last_rec: dict[str, dict] = {}
131
+ with open(path, encoding="utf-8", errors="replace") as fh:
132
+ for line in fh:
133
+ line = line.strip()
134
+ if not line:
135
+ continue
136
+ try:
137
+ rec = json.loads(line)
138
+ raw_ts = rec.get("ts", "")
139
+ ts = datetime.fromisoformat(raw_ts)
140
+ if not ts.tzinfo:
141
+ ts = ts.replace(tzinfo=timezone.utc)
142
+ sid = rec.get("session") or f"_unknown_{line[:12]}"
143
+ if sid not in first_ts:
144
+ first_ts[sid] = ts
145
+ last_rec[sid] = rec
146
+ except Exception:
147
+ continue
148
+ result = []
149
+ for sid, rec in last_rec.items():
150
+ ts = first_ts[sid]
151
+ if start <= ts < end:
152
+ r = dict(rec)
153
+ r["ts"] = ts.isoformat()
154
+ r["_session_id"] = sid
155
+ result.append(r)
156
+ return sorted(result, key=lambda r: r["ts"])
157
+
158
+
159
+ def load_usage_log(cwd: str, days: int) -> tuple[list[dict], list[dict]]:
160
+ """Return (current_window, prev_window) session lists."""
161
+ now = datetime.now(timezone.utc)
162
+ curr_start = now - timedelta(days=days)
163
+ prev_start = now - timedelta(days=2 * days)
164
+ current = _load_log_window(cwd, curr_start, now)
165
+ previous = _load_log_window(cwd, prev_start, curr_start)
166
+ return current, previous
167
+
168
+
169
+ def _parse_transcript(path: str) -> dict:
170
+ """Extract per-session metrics from a transcript JSONL file."""
171
+ peak_context = 0
172
+ agent_dispatches: dict[str, int] = defaultdict(int)
173
+ agent_models: dict[str, list[str]] = defaultdict(list)
174
+ skill_dispatches: dict[str, int] = defaultdict(int)
175
+
176
+ try:
177
+ with open(path, encoding="utf-8", errors="replace") as fh:
178
+ for raw in fh:
179
+ raw = raw.strip()
180
+ if not raw:
181
+ continue
182
+ try:
183
+ ev = json.loads(raw)
184
+ except Exception:
185
+ continue
186
+ msg = ev.get("message") if isinstance(ev.get("message"), dict) else None
187
+ if not msg or msg.get("role") != "assistant":
188
+ continue
189
+ usage = msg.get("usage") or {}
190
+ if isinstance(usage, dict):
191
+ ctx = (int(usage.get("input_tokens") or 0)
192
+ + int(usage.get("cache_read_input_tokens") or 0))
193
+ peak_context = max(peak_context, ctx)
194
+ content = msg.get("content") or []
195
+ if not isinstance(content, list):
196
+ continue
197
+ for item in content:
198
+ if not isinstance(item, dict) or item.get("type") != "tool_use":
199
+ continue
200
+ name = item.get("name", "")
201
+ inp = item.get("input") or {}
202
+ if name == "Agent":
203
+ atype = inp.get("subagent_type") or "unknown"
204
+ model = inp.get("model") or ""
205
+ agent_dispatches[atype] += 1
206
+ if model:
207
+ agent_models[atype].append(model)
208
+ elif name == "Skill":
209
+ skill = inp.get("skill") or "unknown"
210
+ skill_dispatches[skill] += 1
211
+ except Exception:
212
+ pass
213
+
214
+ return {
215
+ "peak_context": peak_context,
216
+ "agent_dispatches": dict(agent_dispatches),
217
+ "agent_models": {k: list(v) for k, v in agent_models.items()},
218
+ "skill_dispatches": dict(skill_dispatches),
219
+ }
220
+
221
+
222
+ def load_transcripts(cwd: str, session_ids: list[str]) -> dict[str, dict]:
223
+ tdir = _transcript_dir(cwd)
224
+ if not tdir:
225
+ return {}
226
+ result = {}
227
+ for sid in session_ids:
228
+ p = tdir / f"{sid}.jsonl"
229
+ if p.is_file():
230
+ result[sid] = _parse_transcript(str(p))
231
+ return result
232
+
233
+
234
+ def load_agent_config(cwd: str) -> list[dict]:
235
+ """Parse frontmatter from agent definition files.
236
+
237
+ Checks paths in priority order:
238
+ 1. $CLAUDE_PLUGIN_ROOT/agents/ — plugin install
239
+ 2. <cwd>/.claude/agents/ — user's project agents
240
+ 3. <cwd>/claude-harness/agents/ — caddis dev checkout
241
+ """
242
+ plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
243
+ candidates = []
244
+ if plugin_root:
245
+ candidates.append(os.path.join(plugin_root, "agents"))
246
+ candidates.append(os.path.join(cwd, ".claude", "agents"))
247
+ candidates.append(os.path.join(cwd, "claude-harness", "agents"))
248
+
249
+ agents_dir = next((c for c in candidates if os.path.isdir(c)), None)
250
+ if not agents_dir:
251
+ return []
252
+ agents = []
253
+ for fname in sorted(os.listdir(agents_dir)):
254
+ if not fname.endswith(".md"):
255
+ continue
256
+ path = os.path.join(agents_dir, fname)
257
+ try:
258
+ text = open(path, encoding="utf-8").read()
259
+ fm = re.search(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
260
+ if not fm:
261
+ continue
262
+ rec: dict[str, str] = {"_file": path}
263
+ for line in fm.group(1).splitlines():
264
+ if ":" in line:
265
+ k, _, v = line.partition(":")
266
+ rec[k.strip()] = v.strip()
267
+ if rec.get("name"):
268
+ agents.append(rec)
269
+ except Exception:
270
+ continue
271
+ return agents
272
+
273
+
274
+ def load_command_roster(cwd: str) -> list[str]:
275
+ """Bare command stems available in this install (e.g. ``implement``, ``handoff``).
276
+
277
+ Modeled on :func:`load_agent_config`: plugin install → project ``.claude`` → caddis dev
278
+ checkout. Each ``<name>.md`` is one command; the plugin prefix (``caddis:``) is added at
279
+ invocation time, so the roster holds bare stems and the never-fired comparison normalizes
280
+ fired names the same way (handles the caddis/claudster prefix divergence — Phase 5).
281
+ """
282
+ plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
283
+ candidates = []
284
+ if plugin_root:
285
+ candidates.append(os.path.join(plugin_root, "commands"))
286
+ candidates.append(os.path.join(cwd, ".claude", "commands"))
287
+ candidates.append(os.path.join(cwd, "claude-harness", "commands"))
288
+ roster: list[str] = []
289
+ for c in candidates:
290
+ if not os.path.isdir(c):
291
+ continue
292
+ try:
293
+ names = sorted(os.listdir(c))
294
+ except Exception:
295
+ continue
296
+ for fname in names:
297
+ if fname.endswith(".md"):
298
+ stem = fname[:-3]
299
+ if stem and stem not in roster:
300
+ roster.append(stem)
301
+ return roster
302
+
303
+
304
+ def _bare_cmd(name: str) -> str:
305
+ """Strip a plugin namespace prefix (``caddis:implement`` / ``claudster:implement`` → ``implement``).
306
+
307
+ Command stems themselves contain no colon, so the segment after the last colon is the stem.
308
+ A bare name (no colon) is returned unchanged.
309
+ """
310
+ return name.rsplit(":", 1)[-1].strip()
311
+
312
+ # ── metrics aggregation ────────────────────────────────────────────────────────
313
+
314
+ def compute_metrics(sessions: list[dict], transcripts: dict[str, dict]) -> dict:
315
+ if not sessions:
316
+ return {
317
+ "sessions": 0, "input": 0, "output": 0,
318
+ "cache_write": 0, "cache_read": 0, "est_cost_usd": 0.0,
319
+ "model_mix": {}, "per_day": {}, "agent_dispatches": {},
320
+ "agent_models": {}, "skill_dispatches": {}, "command_fires": {},
321
+ "peak_contexts": [],
322
+ }
323
+ model_mix: dict[str, int] = defaultdict(int)
324
+ per_day: dict[str, dict] = {}
325
+ tot_input = tot_output = tot_cw = tot_cr = 0
326
+ tot_cost = 0.0
327
+ agent_dispatches: dict[str, int] = defaultdict(int)
328
+ agent_models: dict[str, list[str]] = defaultdict(list)
329
+ skill_dispatches: dict[str, int] = defaultdict(int)
330
+ command_fires: dict[str, int] = defaultdict(int)
331
+ log_skill_fires: dict[str, int] = defaultdict(int)
332
+ peak_contexts: list[int] = []
333
+
334
+ for s in sessions:
335
+ day = s["ts"][:10]
336
+ if day not in per_day:
337
+ per_day[day] = {"sessions": 0, "input": 0, "output": 0,
338
+ "cache_write": 0, "cache_read": 0, "cost": 0.0}
339
+ inp = int(s.get("input") or 0)
340
+ out = int(s.get("output") or 0)
341
+ cw = int(s.get("cache_write") or 0)
342
+ cr = int(s.get("cache_read") or 0)
343
+ cost = float(s.get("est_cost_usd") or 0.0)
344
+
345
+ tot_input += inp; tot_output += out; tot_cw += cw; tot_cr += cr; tot_cost += cost
346
+ pd = per_day[day]
347
+ pd["sessions"] += 1; pd["input"] += inp; pd["output"] += out
348
+ pd["cache_write"] += cw; pd["cache_read"] += cr; pd["cost"] += cost
349
+
350
+ for model in (s.get("models") or []):
351
+ if model == "<synthetic>":
352
+ continue
353
+ model_mix[_tier(model)] += out
354
+
355
+ # Identity from the log (V15 / Phase 11): commands are recorded ONLY in the log
356
+ # (transcripts don't carry them), so the log is the source of truth; skills are
357
+ # unioned in for sessions whose transcript was deleted/compacted.
358
+ for cmd in (s.get("commands") or []):
359
+ command_fires[cmd] += 1
360
+ for sk in (s.get("skills") or []):
361
+ log_skill_fires[sk] += 1
362
+
363
+ sid = s.get("_session_id", "")
364
+ if sid in transcripts:
365
+ td = transcripts[sid]
366
+ ctx = td.get("peak_context", 0)
367
+ if ctx:
368
+ peak_contexts.append(ctx)
369
+ for atype, cnt in td.get("agent_dispatches", {}).items():
370
+ agent_dispatches[atype] += cnt
371
+ for skill, cnt in td.get("skill_dispatches", {}).items():
372
+ skill_dispatches[skill] += cnt
373
+ for atype, models in td.get("agent_models", {}).items():
374
+ agent_models[atype].extend(models)
375
+
376
+ # Log-derived skills fill gaps the surviving transcripts can't (deleted/compacted
377
+ # transcripts): a skill the log recorded but no transcript corroborates still counts.
378
+ for sk, cnt in log_skill_fires.items():
379
+ if sk not in skill_dispatches:
380
+ skill_dispatches[sk] = cnt
381
+
382
+ return {
383
+ "sessions": len(sessions),
384
+ "input": tot_input, "output": tot_output,
385
+ "cache_write": tot_cw, "cache_read": tot_cr,
386
+ "est_cost_usd": round(tot_cost, 2),
387
+ "model_mix": dict(model_mix),
388
+ "per_day": per_day,
389
+ "agent_dispatches": dict(agent_dispatches),
390
+ "agent_models": {k: list(v) for k, v in agent_models.items()},
391
+ "skill_dispatches": dict(skill_dispatches),
392
+ "command_fires": dict(command_fires),
393
+ "peak_contexts": peak_contexts,
394
+ }
395
+
396
+ # ── rule engine ────────────────────────────────────────────────────────────────
397
+
398
+ _CHEAP_AGENTS = {"preflight", "tester", "codebase-audit", "ui-design-reviewer", "data-engineer",
399
+ "sql-expert", "claude-md-curator", "debug"}
400
+ _HEAVY_AGENTS = {"anchor", "security-analyst"}
401
+ # Core skill identity is the caddis plugin's; the pre-rename "claudster:" prefix is also listed so
402
+ # legacy usage-log entries (recorded before the caddis rename) still categorize correctly.
403
+ _CORE_SKILL_NAMES = ("feature-plan", "handoff", "prd", "ship", "tdd", "ui-brief")
404
+ _CORE_SKILLS = {f"{p}:{n}" for p in ("caddis", "claudster") for n in _CORE_SKILL_NAMES}
405
+
406
+
407
+ def run_rules(metrics: dict, prev: dict, agents: list[dict],
408
+ cmd_roster: list[str] | None = None) -> list[dict]:
409
+ findings: list[dict] = []
410
+ mm = metrics.get("model_mix", {})
411
+ tot_out = metrics.get("output") or 1
412
+ sessions = metrics.get("sessions", 0)
413
+ prev_sess = prev.get("sessions", 0)
414
+ ctxs = metrics.get("peak_contexts", [])
415
+ a_disp = metrics.get("agent_dispatches", {})
416
+ s_disp = metrics.get("skill_dispatches", {})
417
+
418
+ # R0 — Rate-limit proxy ────────────────────────────────────────────────────
419
+ heavy_out = mm.get("opus", 0) + mm.get("fable", 0)
420
+ heavy_pct = heavy_out / tot_out * 100 if tot_out else 0
421
+ trend_note = ""
422
+ if prev_sess > 0:
423
+ delta = sessions - prev_sess
424
+ arrow = "↑" if delta > 0 else ("↓" if delta < 0 else "→")
425
+ trend_note = f" ({arrow} from {prev_sess} last window)"
426
+ findings.append({
427
+ "id": "R0", "severity": "info",
428
+ "title": "Your usage this window",
429
+ "finding": (
430
+ f"{sessions} session{'s' if sessions != 1 else ''}{trend_note}. "
431
+ f"{'All on' if heavy_pct >= 99 else f'{heavy_pct:.0f}% used'} Opus or Fable — "
432
+ "the models most likely to hit rate limits. "
433
+ "Actual rate-limit % (5h / weekly cap) is not stored locally."
434
+ ),
435
+ "action": "Run `/usage` in Claude Code to see your real-time rate-limit status.",
436
+ "apply_target": None,
437
+ })
438
+
439
+ # R1 — Agent model tier mismatches ────────────────────────────────────────
440
+ issues: list[str] = []
441
+ apply_agents: list[dict] = []
442
+ for ag in agents:
443
+ name = ag.get("name", "")
444
+ model = ag.get("model", "inherit")
445
+ if model == "inherit" and name in _CHEAP_AGENTS:
446
+ issues.append(f"`{name}` (set to `inherit` — picks up whatever model the session is on)")
447
+ apply_agents.append({"file": ag["_file"], "name": name, "current": model, "suggested": "sonnet"})
448
+ elif model in ("sonnet", "haiku") and name in _HEAVY_AGENTS:
449
+ issues.append(f"`{name}` (set to `{model}` — too light for safety/security verification)")
450
+ apply_agents.append({"file": ag["_file"], "name": name, "current": model, "suggested": "opus"})
451
+ if issues:
452
+ n = len(issues)
453
+ findings.append({
454
+ "id": "R1", "severity": "medium",
455
+ "title": f"{n} agent{'s have' if n > 1 else ' has'} the wrong model set",
456
+ "finding": (
457
+ "These agents are not pinned to the right model tier, meaning they inherit "
458
+ "whatever model you happen to be on — usually Opus: "
459
+ + "; ".join(issues) + "."
460
+ ),
461
+ "action": "Pin each agent to the right tier. Quick agents (preflight, tester) → `sonnet`. "
462
+ "Safety-critical agents (anchor, security-analyst) → `opus`.",
463
+ "apply_target": {"type": "agent_frontmatter", "agents": apply_agents},
464
+ })
465
+
466
+ # R2 — Model mix ──────────────────────────────────────────────────────────
467
+ if heavy_pct > 70 and sessions >= 2:
468
+ findings.append({
469
+ "id": "R2", "severity": "medium",
470
+ "title": f"You're using the heaviest model for {heavy_pct:.0f}% of your work",
471
+ "finding": (
472
+ f"{'All of' if heavy_pct >= 99 else f'{heavy_pct:.0f}% of'} your output this window "
473
+ f"ran on Opus{'or Fable ' if mm.get('fable', 0) > 0 else ''}— the models most likely "
474
+ "to hit rate limits. For everyday tasks like coding, searching, and editing, Sonnet "
475
+ "delivers the same results and uses less of your rate-limit quota."
476
+ ),
477
+ "action": "Switch to Sonnet for day-to-day work: type `/model` and pick Sonnet. "
478
+ "Keep Opus for planning, architecture decisions, and final reviews.",
479
+ "apply_target": None,
480
+ })
481
+
482
+ # R3 — Effort right-sizing (advisory) ─────────────────────────────────────
483
+ findings.append({
484
+ "id": "R3", "severity": "info",
485
+ "title": "Tip: save `max` effort for when it really matters",
486
+ "finding": (
487
+ "Effort level is not recorded in transcripts, so this is a general tip rather than a measured finding. "
488
+ "`max` effort burns rate-limit quota significantly faster than `high` — "
489
+ "reserve it for complex planning and architecture decisions. "
490
+ "For regular coding, `high` is sufficient; for searches and quick edits, `medium` is fine."
491
+ ),
492
+ "action": "Before each task, ask: does this actually need `max`? For most coding tasks, `high` is the right call.",
493
+ "apply_target": None,
494
+ })
495
+
496
+ # R4 — Long context ───────────────────────────────────────────────────────
497
+ if ctxs:
498
+ high_ctxs = [c for c in ctxs if c > 150_000]
499
+ if high_ctxs:
500
+ pct = len(high_ctxs) / len(ctxs) * 100
501
+ sev = "medium" if pct > 50 else "low"
502
+ findings.append({
503
+ "id": "R4", "severity": sev,
504
+ "title": f"{'Most' if pct > 50 else 'Some'} of your sessions are running very long",
505
+ "finding": (
506
+ f"{len(high_ctxs)} of {len(ctxs)} session{'s' if len(ctxs) > 1 else ''} "
507
+ "peaked above 150k tokens of context. Long contexts make Claude slower, "
508
+ "consume more of your rate-limit, and can get truncated above 200k. "
509
+ "This usually means work is piling up without clearing out old context."
510
+ ),
511
+ "action": "Use `/compact` mid-task to summarise context without losing the thread. "
512
+ "Use `/clear` when switching to a completely different task.",
513
+ "apply_target": None,
514
+ })
515
+
516
+ # R5 — Extras footprint ───────────────────────────────────────────────────
517
+ extras_fired = {s for s in s_disp if s.startswith(("caddis:", "claudster:")) and s not in _CORE_SKILLS}
518
+ total_skill_fires = sum(s_disp.values())
519
+ if total_skill_fires > 0 and not extras_fired and sessions >= 3:
520
+ findings.append({
521
+ "id": "R5", "severity": "low",
522
+ "title": "Extras plugin doesn't seem to be getting used",
523
+ "finding": (
524
+ f"You had {total_skill_fires} skill call{'s' if total_skill_fires > 1 else ''} "
525
+ "this window but none from the extras tier. "
526
+ "If caddis-extras is enabled, it adds context to every session even when unused."
527
+ ),
528
+ "action": "Disable caddis-extras if you don't use those skills: remove it from the plugins list in `.claude/settings.json`.",
529
+ "apply_target": None,
530
+ })
531
+
532
+ # R6 — Per-agent heavy dispatch ───────────────────────────────────────────
533
+ if a_disp:
534
+ heavy: list[tuple[str, int, str]] = []
535
+ for atype, cnt in sorted(a_disp.items(), key=lambda x: -x[1]):
536
+ models = metrics.get("agent_models", {}).get(atype, [])
537
+ if models:
538
+ from collections import Counter
539
+ dominant = Counter(_tier(m) for m in models).most_common(1)[0][0]
540
+ if dominant in ("opus", "fable"):
541
+ heavy.append((atype, cnt, dominant))
542
+ if heavy:
543
+ names = ", ".join(f"`{t}`" for t, _, _ in heavy[:4])
544
+ n = len(heavy)
545
+ findings.append({
546
+ "id": "R6", "severity": "medium",
547
+ "title": f"{n} subagent{'s are' if n > 1 else ' is'} running on Opus unnecessarily",
548
+ "finding": (
549
+ f"These subagents dispatched on Opus this window: {names}. "
550
+ "Most subagents don't need Opus-level reasoning — Sonnet handles code review, "
551
+ "preflight checks, and testing just as well at a lower rate-limit cost."
552
+ ),
553
+ "action": "Pin these agents to `model: sonnet` in their frontmatter files.",
554
+ "apply_target": {
555
+ "type": "agent_frontmatter",
556
+ "agents": [{"name": t, "suggested": "sonnet"} for t, _, _ in heavy],
557
+ },
558
+ })
559
+
560
+ # R7 — Dead weight ────────────────────────────────────────────────────────
561
+ if sessions >= 5 and a_disp:
562
+ all_names = {ag.get("name", "") for ag in agents} - {""}
563
+ fired = set(a_disp.keys())
564
+ never = all_names - fired
565
+ if len(never) >= 4 and len(never) / max(len(all_names), 1) > 0.5:
566
+ sample = ", ".join(f"`{n}`" for n in sorted(never)[:5])
567
+ n_never = len(never)
568
+ findings.append({
569
+ "id": "R7", "severity": "low",
570
+ "title": f"{n_never} agents were never dispatched this window",
571
+ "finding": (
572
+ f"These agents haven't been used at all: {sample}"
573
+ f"{'…' if n_never > 5 else ''}. "
574
+ "Unused agents don't cost much, but if they're consistently idle across "
575
+ "multiple weeks it may be worth moving them to extras."
576
+ ),
577
+ "action": "If these agents aren't part of your workflow, consider moving them to the extras tier.",
578
+ "apply_target": None,
579
+ })
580
+
581
+ # R8 — Trend ──────────────────────────────────────────────────────────────
582
+ prev_out = prev.get("output", 0)
583
+ if prev_out > 0 and metrics.get("output", 0) > 0:
584
+ delta_pct = (metrics["output"] - prev_out) / prev_out * 100
585
+ if abs(delta_pct) >= 40:
586
+ direction = "up" if delta_pct > 0 else "down"
587
+ findings.append({
588
+ "id": "R8", "severity": "info",
589
+ "title": f"Your usage is {direction} {abs(delta_pct):.0f}% vs last window",
590
+ "finding": (
591
+ f"Output token volume went {direction} {abs(delta_pct):.0f}% "
592
+ f"({_fmt(prev_out)} → {_fmt(metrics['output'])}) compared to the previous window."
593
+ + (" If it's trending up, check whether a new workflow or heavy agent is the cause."
594
+ if delta_pct > 0 else "")
595
+ ),
596
+ "action": "Check which sessions or agents are driving the change." if delta_pct > 0 else "",
597
+ "apply_target": None,
598
+ })
599
+
600
+ # R9 — Never-fired commands (pruning evidence) ───────────────────────────────
601
+ # V15 / Phase 11: identity in the log now lets us name commands that ship but never
602
+ # fired in the window. This is the evidence Phase 12's prune runs on — a command the
603
+ # log never sees across enough sessions is a pruning candidate backed by data, not a guess.
604
+ cmd_fires = metrics.get("command_fires", {})
605
+ cmd_roster = cmd_roster or []
606
+ if sessions >= 3 and cmd_roster:
607
+ fired_bare = {_bare_cmd(n) for n in cmd_fires}
608
+ never = sorted(c for c in cmd_roster if c not in fired_bare)
609
+ if never:
610
+ shown = never[:8]
611
+ sample = ", ".join(f"`{n}`" for n in shown)
612
+ n_never = len(never)
613
+ findings.append({
614
+ "id": "R9", "severity": "low",
615
+ "title": f"{n_never} command{'s' if n_never != 1 else ''} never fired this window",
616
+ "finding": (
617
+ "These commands ship in the install but the log shows no invocation this "
618
+ f"window: {sample}{'…' if n_never > 8 else ''}. "
619
+ "Consistently-idle commands are pruning candidates — but confirm against "
620
+ "transcripts before removing, since a command may see use outside this repo."
621
+ ),
622
+ "action": "Cross-check the 90-day transcript history before removing any command "
623
+ "(Phase 12). A command never seen anywhere is redundant; one used rarely "
624
+ "may still earn its place.",
625
+ "apply_target": None,
626
+ })
627
+
628
+ return findings
629
+
630
+ # ── format helpers ─────────────────────────────────────────────────────────────
631
+
632
+ def _fmt(n: int) -> str:
633
+ if n >= 1_000_000:
634
+ return f"{n / 1_000_000:.1f}M"
635
+ if n >= 1_000:
636
+ return f"{n / 1_000:.1f}k"
637
+ return str(n)
638
+
639
+
640
+ def _pct(a: int, b: int) -> str:
641
+ return f"{a / b * 100:.0f}%" if b else "—"
642
+
643
+ # ── markdown renderer ──────────────────────────────────────────────────────────
644
+
645
+ def render_markdown(metrics: dict, findings: list[dict], days: int,
646
+ ws: str, we: str) -> str:
647
+ mm = metrics.get("model_mix", {})
648
+ tot_out = metrics.get("output") or 1
649
+ mix = " · ".join(
650
+ f"{t}: {_pct(v, tot_out)}"
651
+ for t, v in sorted(mm.items(), key=lambda x: -x[1])
652
+ ) or "no model data"
653
+
654
+ lines = [
655
+ "# /usage-review",
656
+ f"**Window:** {days}d ({ws} – {we}) · **Sessions:** {metrics['sessions']} · {mix}",
657
+ "",
658
+ "| Metric | Value |",
659
+ "|---|---|",
660
+ f"| Output tokens | {_fmt(metrics['output'])} |",
661
+ f"| Input + cache read | {_fmt(metrics['input'] + metrics['cache_read'])} |",
662
+ f"| Cache efficiency | {_pct(metrics['cache_read'], metrics['input'] + metrics['cache_read'])} read from cache |",
663
+ f"| Est. cost equiv. | ${metrics['est_cost_usd']:.2f} (estimate; edit rates in session_end.py) |",
664
+ "",
665
+ "## Findings",
666
+ "",
667
+ ]
668
+
669
+ sev_order = {"high": 0, "medium": 1, "low": 2, "info": 3}
670
+ icon = {"high": "🔴", "medium": "🟡", "low": "🔵", "info": "ℹ️"}
671
+ for f in sorted(findings, key=lambda x: sev_order.get(x["severity"], 4)):
672
+ lines.append(f"### {icon.get(f['severity'], '•')} [{f['id']}] {f['title']}")
673
+ lines.append("")
674
+ lines.append(f" {f['finding']}")
675
+ lines.append("")
676
+ if f.get("action"):
677
+ lines.append(f" **Action:** {f['action']}")
678
+ if f.get("apply_target"):
679
+ lines.append(f" **[apply]** — tell Claude: _apply finding {f['id']}_")
680
+ lines.append("")
681
+
682
+ if metrics.get("agent_dispatches"):
683
+ lines += ["## Agent dispatches this window", ""]
684
+ am = metrics.get("agent_models", {})
685
+ for atype, cnt in sorted(metrics["agent_dispatches"].items(), key=lambda x: -x[1]):
686
+ models = am.get(atype, [])
687
+ note = f" (on {', '.join(sorted(set(models)))})" if models else ""
688
+ lines.append(f"- `{atype}` × {cnt}{note}")
689
+ lines.append("")
690
+
691
+ if metrics.get("skill_dispatches"):
692
+ lines += ["## Skills fired this window", ""]
693
+ for skill, cnt in sorted(metrics["skill_dispatches"].items(), key=lambda x: -x[1]):
694
+ lines.append(f"- `{skill}` × {cnt}")
695
+ lines.append("")
696
+
697
+ lines += [
698
+ "---",
699
+ "HTML dashboard written alongside this report — see path printed by the script.",
700
+ ]
701
+ return "\n".join(lines)
702
+
703
+ # ── SVG chart helpers ──────────────────────────────────────────────────────────
704
+
705
+ def _svg_empty(w: int, h: int, msg: str = "No data") -> str:
706
+ return (
707
+ f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg">'
708
+ f'<text x="{w//2}" y="{h//2}" text-anchor="middle" dominant-baseline="middle" '
709
+ f'fill="#6f6857" font-size="12" font-family="system-ui,sans-serif">{msg}</text>'
710
+ f'</svg>'
711
+ )
712
+
713
+
714
+ def _svg_timeline(data: dict[str, float], color: str = "#E8A04C",
715
+ w: int = 380, h: int = 120) -> str:
716
+ """Single-series line + area chart keyed by YYYY-MM-DD."""
717
+ if not data or max(data.values(), default=0) == 0:
718
+ return _svg_empty(w, h)
719
+ days = sorted(data)
720
+ vals = [data[d] for d in days]
721
+ max_v = max(vals) or 1
722
+ pad_l, pad_r, pad_t, pad_b = 4, 4, 8, 20
723
+ aw = w - pad_l - pad_r
724
+ ah = h - pad_t - pad_b
725
+ n = max(len(vals) - 1, 1)
726
+
727
+ pts = []
728
+ for i, v in enumerate(vals):
729
+ x = pad_l + i / n * aw
730
+ y = pad_t + ah - v / max_v * ah
731
+ pts.append((x, y))
732
+
733
+ line_pts = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
734
+ area_pts = (
735
+ f"{pts[0][0]:.1f},{pad_t + ah:.1f} "
736
+ + line_pts
737
+ + f" {pts[-1][0]:.1f},{pad_t + ah:.1f}"
738
+ )
739
+
740
+ # x-axis labels (first + last date)
741
+ label_y = h - 4
742
+ labels = (
743
+ f'<text x="{pad_l}" y="{label_y}" font-size="9" fill="#6f6857">{days[0][5:]}</text>'
744
+ f'<text x="{w - pad_r}" y="{label_y}" font-size="9" fill="#6f6857" text-anchor="end">{days[-1][5:]}</text>'
745
+ )
746
+ return (
747
+ f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
748
+ f' font-family="system-ui,sans-serif" style="cursor:crosshair">'
749
+ f'<polygon points="{area_pts}" fill="{color}" fill-opacity="0.12"/>'
750
+ f'<polyline points="{line_pts}" fill="none" stroke="{color}"'
751
+ f' stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
752
+ + "".join(
753
+ f'<circle cx="{x:.1f}" cy="{y:.1f}" r="2.5" fill="{color}">'
754
+ f'<title>{days[i][5:]}: {vals[i]:.0f}</title>'
755
+ f'</circle>'
756
+ for i, (x, y) in enumerate(pts)
757
+ )
758
+ + labels
759
+ + "</svg>"
760
+ )
761
+
762
+
763
+ def _svg_stacked_bar(per_day: dict[str, dict], w: int = 380, h: int = 130) -> str:
764
+ """Stacked bar: output (amber) + cache_read (blue) per day."""
765
+ if not per_day:
766
+ return _svg_empty(w, h)
767
+ days = sorted(per_day)
768
+ out_vals = [per_day[d].get("output", 0) for d in days]
769
+ cr_vals = [per_day[d].get("cache_read", 0) for d in days]
770
+ max_v = max((a + b for a, b in zip(out_vals, cr_vals)), default=0) or 1
771
+ pad_l, pad_r, pad_t, pad_b = 4, 4, 8, 20
772
+ aw = w - pad_l - pad_r
773
+ ah = h - pad_t - pad_b
774
+ bar_w = max(aw / max(len(days), 1) - 3, 4)
775
+ n = max(len(days) - 1, 1)
776
+ parts = []
777
+ for i, day in enumerate(days):
778
+ x = pad_l + i / max(len(days) - 1, 1) * aw - bar_w / 2
779
+ x = max(pad_l, min(x, w - pad_r - bar_w))
780
+ out_h = out_vals[i] / max_v * ah
781
+ cr_h = cr_vals[i] / max_v * ah
782
+ # cache_read bar (bottom)
783
+ cr_y = pad_t + ah - cr_h
784
+ parts.append(
785
+ f'<rect x="{x:.1f}" y="{cr_y:.1f}" width="{bar_w:.1f}" height="{cr_h:.1f}"'
786
+ f' rx="2" fill="#6B9BC9" fill-opacity="0.7">'
787
+ f'<title>{day[5:]}: cache read {_fmt(int(cr_vals[i]))}</title>'
788
+ f'</rect>'
789
+ )
790
+ # output bar (stacked on top)
791
+ out_y = pad_t + ah - cr_h - out_h
792
+ parts.append(
793
+ f'<rect x="{x:.1f}" y="{out_y:.1f}" width="{bar_w:.1f}" height="{out_h:.1f}"'
794
+ f' rx="2" fill="#E8A04C" fill-opacity="0.85">'
795
+ f'<title>{day[5:]}: output {_fmt(int(out_vals[i]))}</title>'
796
+ f'</rect>'
797
+ )
798
+ # x labels
799
+ label_y = h - 4
800
+ parts.append(f'<text x="{pad_l}" y="{label_y}" font-size="9" fill="#6f6857">{days[0][5:]}</text>')
801
+ if len(days) > 1:
802
+ parts.append(f'<text x="{w - pad_r}" y="{label_y}" font-size="9" fill="#6f6857" text-anchor="end">{days[-1][5:]}</text>')
803
+ # legend
804
+ parts.append(f'<rect x="{w-80}" y="4" width="8" height="8" rx="1" fill="#E8A04C"/>')
805
+ parts.append(f'<text x="{w-70}" y="11" font-size="9" fill="#9a9282">output</text>')
806
+ parts.append(f'<rect x="{w-80}" y="16" width="8" height="8" rx="1" fill="#6B9BC9"/>')
807
+ parts.append(f'<text x="{w-70}" y="23" font-size="9" fill="#9a9282">cache</text>')
808
+ return (
809
+ f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
810
+ f' font-family="system-ui,sans-serif">'
811
+ + "".join(parts)
812
+ + "</svg>"
813
+ )
814
+
815
+
816
+ def _svg_donut(slices: list[tuple[str, float, str]], size: int = 160) -> str:
817
+ """Donut chart. slices = [(label, value, color)]."""
818
+ total = sum(v for _, v, _ in slices)
819
+ if not total:
820
+ return _svg_empty(size, size, "No data")
821
+ cx = cy = size / 2
822
+ r = size / 2 - 22
823
+ sw = 20
824
+ circ = 2 * 3.14159265 * r
825
+ parts = []
826
+ offset = 0.0
827
+ for label, value, color in slices:
828
+ pct = value / total
829
+ dash = pct * circ
830
+ parts.append(
831
+ f'<circle cx="{cx}" cy="{cy}" r="{r:.1f}" fill="none" stroke="{color}"'
832
+ f' stroke-width="{sw}" stroke-dasharray="{dash:.2f} {circ:.2f}"'
833
+ f' stroke-dashoffset="{-offset:.2f}"'
834
+ f' transform="rotate(-90 {cx} {cy})" style="cursor:crosshair">'
835
+ f'<title>{label}: {pct*100:.1f}%</title>'
836
+ f'</circle>'
837
+ )
838
+ offset += dash
839
+ # center label
840
+ parts.append(
841
+ f'<text x="{cx}" y="{cy - 5}" text-anchor="middle" dominant-baseline="middle"'
842
+ f' font-size="11" font-weight="600" fill="#F5EFE0" font-family="system-ui,sans-serif">'
843
+ + slices[0][0][:8] + "</text>"
844
+ )
845
+ parts.append(
846
+ f'<text x="{cx}" y="{cy + 10}" text-anchor="middle" dominant-baseline="middle"'
847
+ f' font-size="10" fill="#9a9282" font-family="system-ui,sans-serif">'
848
+ f"{slices[0][1] / total * 100:.0f}%</text>"
849
+ )
850
+ return (
851
+ f'<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">'
852
+ + "".join(parts)
853
+ + "</svg>"
854
+ )
855
+
856
+
857
+ def _svg_histogram(values: list[int], bins: int = 6,
858
+ w: int = 320, h: int = 120) -> str:
859
+ """Histogram of context sizes (in tokens)."""
860
+ if not values:
861
+ return _svg_empty(w, h)
862
+ max_v = max(values)
863
+ bin_size = max(max_v // bins, 1)
864
+ counts = [0] * bins
865
+ for v in values:
866
+ idx = min(int(v / bin_size), bins - 1)
867
+ counts[idx] += 1
868
+ max_count = max(counts) or 1
869
+ pad_l, pad_r, pad_t, pad_b = 4, 4, 8, 24
870
+ aw = w - pad_l - pad_r
871
+ ah = h - pad_t - pad_b
872
+ bar_w = aw / bins - 3
873
+ parts = []
874
+ for i, cnt in enumerate(counts):
875
+ x = pad_l + i * (aw / bins)
876
+ bar_h = cnt / max_count * ah
877
+ y = pad_t + ah - bar_h
878
+ color = "#C9736B" if (i + 1) * bin_size > 150_000 else "#E8A04C"
879
+ lo_k = i * bin_size // 1000
880
+ hi_k = (i + 1) * bin_size // 1000
881
+ parts.append(
882
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{bar_h:.1f}"'
883
+ f' rx="2" fill="{color}" fill-opacity="0.8" style="cursor:crosshair">'
884
+ f'<title>{lo_k}k–{hi_k}k tokens: {cnt} session{"s" if cnt != 1 else ""}</title>'
885
+ f'</rect>'
886
+ )
887
+ label = f"{lo_k}k"
888
+ parts.append(
889
+ f'<text x="{x + bar_w/2:.1f}" y="{h - 6}" text-anchor="middle"'
890
+ f' font-size="8" fill="#6f6857">{label}</text>'
891
+ )
892
+ # 150k threshold line
893
+ if max_v > 150_000:
894
+ tx = pad_l + (150_000 / max(max_v, 1)) * aw
895
+ parts.append(
896
+ f'<line x1="{tx:.1f}" y1="{pad_t}" x2="{tx:.1f}" y2="{pad_t + ah}"'
897
+ f' stroke="#C9736B" stroke-width="1" stroke-dasharray="3,2"/>'
898
+ )
899
+ parts.append(
900
+ f'<text x="{tx + 2:.1f}" y="{pad_t + 8}" font-size="8" fill="#C9736B">150k</text>'
901
+ )
902
+ return (
903
+ f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
904
+ f' font-family="system-ui,sans-serif">'
905
+ + "".join(parts)
906
+ + "</svg>"
907
+ )
908
+
909
+
910
+ def _svg_bar_h(items: list[tuple[str, int, str]], w: int = 320, h: int = 160) -> str:
911
+ """Horizontal bar chart. items = [(label, value, color)]."""
912
+ if not items:
913
+ return _svg_empty(w, h)
914
+ max_v = max(v for _, v, _ in items) or 1
915
+ n = len(items)
916
+ row_h = h / n
917
+ bar_h = min(row_h * 0.55, 22)
918
+ lw = 110
919
+ bw = w - lw - 36
920
+ parts = []
921
+ for i, (label, value, color) in enumerate(items):
922
+ y_mid = i * row_h + row_h / 2
923
+ parts.append(
924
+ f'<text x="{lw - 6}" y="{y_mid + 4:.1f}" text-anchor="end"'
925
+ f' font-size="11" fill="#9a9282">{label[:18]}</text>'
926
+ )
927
+ bar_w = value / max_v * bw
928
+ y_bar = y_mid - bar_h / 2
929
+ parts.append(
930
+ f'<rect x="{lw}" y="{y_bar:.1f}" width="{bar_w:.1f}" height="{bar_h:.1f}"'
931
+ f' rx="3" fill="{color}" fill-opacity="0.8" style="cursor:crosshair">'
932
+ f'<title>{label}: {value} dispatch{"es" if value != 1 else ""}</title>'
933
+ f'</rect>'
934
+ )
935
+ parts.append(
936
+ f'<text x="{lw + bar_w + 4:.1f}" y="{y_mid + 4:.1f}"'
937
+ f' font-size="10" fill="#6f6857">{value}</text>'
938
+ )
939
+ return (
940
+ f'<svg viewBox="0 0 {w} {h}" xmlns="http://www.w3.org/2000/svg"'
941
+ f' font-family="system-ui,sans-serif">'
942
+ + "".join(parts)
943
+ + "</svg>"
944
+ )
945
+
946
+ # ── HTML renderer ──────────────────────────────────────────────────────────────
947
+
948
+ _CSS = """
949
+ :root {
950
+ --bg:#16140f;--bg-2:#1c1a14;--surface:#242018;--surface-2:#2c281f;
951
+ --line:#3a352a;--ink-1:#F5EFE0;--ink-2:#C9C2B2;--ink-3:#9a9282;--ink-4:#6f6857;
952
+ --amber:#E8A04C;--amber-soft:#f0c089;--sage:#8FA66B;--rose:#C9736B;--blue:#6B9BC9;
953
+ --radius:14px;--shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px -12px rgba(0,0,0,.6);
954
+ }
955
+ *{box-sizing:border-box;margin:0;padding:0}
956
+ body{background:var(--bg);color:var(--ink-1);
957
+ font-family:'Plus Jakarta Sans',system-ui,-apple-system,'Segoe UI',sans-serif;
958
+ font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
959
+ h1,h2,h3.display-font{font-family:'Space Grotesk','Segoe UI',system-ui,sans-serif;letter-spacing:-.02em}
960
+ .page{max-width:1040px;margin:0 auto;padding:48px 24px 80px}
961
+ h1{font-size:38px;font-weight:700;letter-spacing:-.05em}
962
+ .subtitle{color:var(--ink-3);font-size:14px;margin-top:6px}
963
+ .brand-strip{margin-bottom:28px;padding-bottom:20px;border-bottom:1px solid var(--line)}
964
+ /* running hamster logo */
965
+ .hamster-logo{display:flex;align-items:center;gap:20px}
966
+ .h-run{position:relative;height:6em;width:9ch;
967
+ font-family:'JetBrains Mono',ui-monospace,'Cascadia Code',Consolas,monospace;
968
+ color:var(--amber);font-size:11px;line-height:1.5}
969
+ .rf{position:absolute;top:0;left:0;margin:0;white-space:pre}
970
+ .rf1{animation:rf-a .9s linear infinite 0s}
971
+ .rf2{animation:rf-a .9s linear infinite -.6s}
972
+ .rf3{animation:rf-a .9s linear infinite -.3s}
973
+ @keyframes rf-a{0%,32.9%{opacity:1}33%,100%{opacity:0}}
974
+ .h-brand{display:flex;flex-direction:column;gap:5px;padding-left:4px}
975
+ .brand-name{font-family:'Space Grotesk',system-ui,sans-serif;font-size:13px;font-weight:700;
976
+ letter-spacing:.08em;text-transform:uppercase;color:var(--amber)}
977
+ .brand-tag{font-size:12px;color:var(--ink-4)}
978
+ .summary-bar{display:inline-flex;align-items:center;gap:10px;margin-top:16px;
979
+ padding:8px 16px;background:var(--surface);border:1px solid var(--line);
980
+ border-radius:999px;font-size:13px;font-weight:600;color:var(--ink-2)}
981
+ .summary-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
982
+ .summary-dot.ok{background:var(--sage)}
983
+ .summary-dot.warn{background:var(--amber)}
984
+ .summary-dot.bad{background:var(--rose)}
985
+ .section-label{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
986
+ color:var(--ink-4);margin:40px 0 14px;display:flex;align-items:center;gap:10px}
987
+ .section-label::after{content:"";flex:1;height:1px;background:var(--line)}
988
+ .section-label.warn::before{content:"";width:6px;height:6px;border-radius:50%;
989
+ background:var(--amber);flex-shrink:0}
990
+ .section-label.info-lbl::before{content:"";width:6px;height:6px;border-radius:50%;
991
+ background:var(--ink-4);flex-shrink:0}
992
+ .pills{display:flex;gap:12px;flex-wrap:wrap;margin:20px 0 0}
993
+ .pill{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
994
+ padding:14px 20px;min-width:118px;box-shadow:var(--shadow)}
995
+ .pill .n{font-size:24px;font-weight:700;letter-spacing:-.03em;color:var(--amber);line-height:1;
996
+ font-family:'Space Grotesk',system-ui,sans-serif}
997
+ .pill .l{font-size:10px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;
998
+ color:var(--ink-4);margin-top:7px}
999
+ .charts-label{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
1000
+ color:var(--ink-4);margin:40px 0 14px;display:flex;align-items:center;gap:10px}
1001
+ .charts-label::after{content:"";flex:1;height:1px;background:var(--line)}
1002
+ .charts{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
1003
+ .chart-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
1004
+ padding:20px;box-shadow:var(--shadow)}
1005
+ .chart-card h3{font-size:11px;font-weight:700;color:var(--ink-4);letter-spacing:.07em;
1006
+ text-transform:uppercase;margin-bottom:2px;font-family:'Space Grotesk',system-ui,sans-serif}
1007
+ .chart-card svg{width:100%;height:auto;display:block;margin-top:10px}
1008
+ .findings{display:flex;flex-direction:column;gap:10px}
1009
+ .finding{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
1010
+ padding:20px 22px;box-shadow:var(--shadow)}
1011
+ .finding.medium{border-left:3px solid var(--amber)}
1012
+ .finding.high{border-left:3px solid var(--rose)}
1013
+ .finding.low{border-left:3px solid var(--blue)}
1014
+ .finding.info{border-left:3px solid var(--line)}
1015
+ .finding-meta{display:flex;align-items:center;gap:8px;margin-bottom:8px}
1016
+ .finding-badge{font-size:10px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;
1017
+ background:var(--bg-2);border:1px solid var(--line);border-radius:5px;
1018
+ padding:2px 7px;color:var(--ink-4);font-family:'JetBrains Mono',monospace}
1019
+ .finding-sev{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
1020
+ .finding-sev.medium{color:var(--amber)}
1021
+ .finding-sev.high{color:var(--rose)}
1022
+ .finding-sev.low{color:var(--blue)}
1023
+ .finding-sev.info{color:var(--ink-4)}
1024
+ .finding-title{font-size:16px;font-weight:600;color:var(--ink-1);margin-bottom:10px;
1025
+ letter-spacing:-.01em;line-height:1.3;font-family:'Space Grotesk',system-ui,sans-serif}
1026
+ .finding-body{font-size:14px;color:var(--ink-2);line-height:1.65}
1027
+ .fix-block{margin-top:14px;padding:12px 16px;background:rgba(232,160,76,.07);
1028
+ border:1px solid rgba(232,160,76,.2);border-radius:10px;
1029
+ font-size:13px;color:var(--amber-soft);line-height:1.6}
1030
+ .fix-label{font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
1031
+ color:var(--amber);margin-bottom:5px}
1032
+ .apply-tag{display:inline-block;background:rgba(232,160,76,.12);
1033
+ border:1px solid rgba(232,160,76,.3);border-radius:6px;padding:2px 8px;
1034
+ font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;
1035
+ color:var(--amber-soft);margin-left:8px;vertical-align:middle}
1036
+ .legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:10px}
1037
+ .legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--ink-3)}
1038
+ .legend-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
1039
+ code{font-family:'JetBrains Mono',ui-monospace,'Cascadia Code',Consolas,monospace;
1040
+ background:var(--bg-2);padding:1px 5px;border-radius:4px;font-size:.85em;color:var(--amber-soft)}
1041
+ footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);
1042
+ font-size:12px;color:var(--ink-4)}
1043
+ """
1044
+
1045
+
1046
+ def _pill(value: str, label: str) -> str:
1047
+ return (
1048
+ f'<div class="pill"><div class="n">{value}</div>'
1049
+ f'<div class="l">{label}</div></div>'
1050
+ )
1051
+
1052
+
1053
+ def _backtick_to_code(text: str) -> str:
1054
+ """Replace `foo` with <code>foo</code> for HTML display."""
1055
+ return re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
1056
+
1057
+
1058
+ def _card_html(f: dict) -> str:
1059
+ sev = f["severity"]
1060
+ sev_labels = {"high": "Needs fixing", "medium": "Worth fixing", "low": "Minor", "info": "Good to know"}
1061
+ apply_tag = '<span class="apply-tag">apply</span>' if f.get("apply_target") else ""
1062
+ body = _backtick_to_code(f["finding"])
1063
+ fix_html = ""
1064
+ if f.get("action"):
1065
+ fix_html = (
1066
+ f'<div class="fix-block">'
1067
+ f'<div class="fix-label">What to do</div>'
1068
+ + _backtick_to_code(f["action"])
1069
+ + "</div>"
1070
+ )
1071
+ return (
1072
+ f'<div class="finding {sev}">'
1073
+ f'<div class="finding-meta">'
1074
+ f'<span class="finding-badge">{f["id"]}</span>'
1075
+ f'<span class="finding-sev {sev}">{sev_labels.get(sev, sev)}</span>'
1076
+ f'</div>'
1077
+ f'<div class="finding-title">{f["title"]}{apply_tag}</div>'
1078
+ f'<div class="finding-body">{body}</div>'
1079
+ + fix_html
1080
+ + "</div>"
1081
+ )
1082
+
1083
+
1084
+ def render_html(metrics: dict, findings: list[dict], days: int,
1085
+ ws: str, we: str, generated: str,
1086
+ log_path: str = ".caddis/usage-log.jsonl") -> str:
1087
+ # log_path is shown in the footer so the dashboard names the dir this repo actually uses.
1088
+ mm = metrics.get("model_mix", {})
1089
+ tot_out = metrics.get("output") or 1
1090
+ sessions = metrics.get("sessions", 0)
1091
+ per_day = metrics.get("per_day", {})
1092
+
1093
+ # Summary bar
1094
+ action_count = sum(1 for f in findings if f["severity"] in ("high", "medium"))
1095
+ info_count = len(findings) - action_count
1096
+ if action_count == 0:
1097
+ dot_cls = "ok"
1098
+ summary_text = f"Nothing needs immediate attention &middot; {info_count} tip{'s' if info_count != 1 else ''}"
1099
+ elif action_count <= 2:
1100
+ dot_cls = "warn"
1101
+ summary_text = (
1102
+ f"{action_count} thing{'s' if action_count != 1 else ''} "
1103
+ f"need{'s' if action_count == 1 else ''} attention "
1104
+ f"&middot; {info_count} tip{'s' if info_count != 1 else ''}"
1105
+ )
1106
+ else:
1107
+ dot_cls = "bad"
1108
+ summary_text = f"{action_count} things need attention &middot; {info_count} tips"
1109
+
1110
+ # Pills
1111
+ mix_top = max(mm.items(), key=lambda x: x[1], default=("—", 0))
1112
+ pills = "".join([
1113
+ _pill(str(sessions), "sessions"),
1114
+ _pill(_fmt(metrics["output"]), "output tokens"),
1115
+ _pill(_pct(metrics["cache_read"], metrics["input"] + metrics["cache_read"]), "cache efficiency"),
1116
+ _pill(f"{mix_top[0]}: {_pct(mix_top[1], tot_out)}", "top model"),
1117
+ ])
1118
+
1119
+ # Charts
1120
+ per_day_cost = {d: v["cost"] for d, v in per_day.items()}
1121
+ per_day_sessions = {d: v["sessions"] for d, v in per_day.items()}
1122
+ mix_slices = [
1123
+ (t, mm.get(t, 0), TIER_COLORS.get(t, "#9a9282"))
1124
+ for t in ["fable", "opus", "sonnet", "haiku"]
1125
+ if mm.get(t, 0) > 0
1126
+ ]
1127
+ a_disp = metrics.get("agent_dispatches", {})
1128
+ agent_items = [
1129
+ (t, cnt, TIER_COLORS.get(
1130
+ _tier((metrics.get("agent_models", {}).get(t) or ["sonnet"])[0]),
1131
+ "#E8A04C"
1132
+ ))
1133
+ for t, cnt in sorted(a_disp.items(), key=lambda x: -x[1])[:8]
1134
+ ]
1135
+
1136
+ charts_html = (
1137
+ f'<div class="chart-card"><h3>Sessions</h3>'
1138
+ + _svg_timeline(per_day_sessions, "#8FA66B") + "</div>"
1139
+ + f'<div class="chart-card"><h3>Est. API cost equiv. per day</h3>'
1140
+ + _svg_timeline(per_day_cost, "#E8A04C") + "</div>"
1141
+ + f'<div class="chart-card"><h3>Token volume per day</h3>'
1142
+ + _svg_stacked_bar(per_day)
1143
+ + '<div class="legend">'
1144
+ + '<div class="legend-item"><div class="legend-dot" style="background:#E8A04C"></div>output</div>'
1145
+ + '<div class="legend-item"><div class="legend-dot" style="background:#6B9BC9"></div>cache read</div>'
1146
+ + "</div></div>"
1147
+ + '<div class="chart-card" style="display:flex;flex-direction:column;align-items:center">'
1148
+ + '<h3 style="width:100%">Model mix (by output tokens)</h3>'
1149
+ + f'<div style="width:160px;margin:4px auto 0">{_svg_donut(mix_slices)}</div>'
1150
+ + '<div class="legend" style="justify-content:center;margin-top:8px">'
1151
+ + "".join(
1152
+ f'<div class="legend-item"><div class="legend-dot" style="background:{c}"></div>{t}</div>'
1153
+ for t, _, c in mix_slices
1154
+ )
1155
+ + "</div></div>"
1156
+ )
1157
+ if metrics.get("peak_contexts"):
1158
+ charts_html += (
1159
+ '<div class="chart-card"><h3>Context size per session</h3>'
1160
+ + _svg_histogram(metrics["peak_contexts"])
1161
+ + '<div class="legend"><div class="legend-item">'
1162
+ + '<div class="legend-dot" style="background:#C9736B"></div>above 150k (heavy)</div></div></div>'
1163
+ )
1164
+ if a_disp:
1165
+ charts_html += (
1166
+ '<div class="chart-card"><h3>Agent usage</h3>'
1167
+ + _svg_bar_h(agent_items, h=max(60, len(agent_items) * 30))
1168
+ + "</div>"
1169
+ )
1170
+
1171
+ # Findings — split into "needs attention" and "good to know"
1172
+ sev_order = {"high": 0, "medium": 1, "low": 2, "info": 3}
1173
+ sorted_f = sorted(findings, key=lambda x: sev_order.get(x["severity"], 4))
1174
+ action_cards = [_card_html(f) for f in sorted_f if f["severity"] in ("high", "medium")]
1175
+ advisory_cards = [_card_html(f) for f in sorted_f if f["severity"] in ("low", "info")]
1176
+
1177
+ action_section = ""
1178
+ if action_cards:
1179
+ action_section = (
1180
+ '<div class="section-label warn">Needs attention</div>'
1181
+ + '<div class="findings">' + "\n".join(action_cards) + "</div>"
1182
+ )
1183
+ advisory_section = ""
1184
+ if advisory_cards:
1185
+ advisory_section = (
1186
+ '<div class="section-label info-lbl">Good to know</div>'
1187
+ + '<div class="findings">' + "\n".join(advisory_cards) + "</div>"
1188
+ )
1189
+
1190
+ return f"""<!DOCTYPE html>
1191
+ <html lang="en">
1192
+ <head>
1193
+ <meta charset="UTF-8">
1194
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1195
+ <title>caddis · usage review</title>
1196
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1197
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1198
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
1199
+ <style>{_CSS}</style>
1200
+ </head>
1201
+ <body>
1202
+ <div class="page">
1203
+ <div class="brand-strip">
1204
+ <div class="hamster-logo">
1205
+ <div class="h-run">
1206
+ <pre class="rf rf1"> (\\(\\
1207
+ ( ˘ω˘ )
1208
+ (っ っ)
1209
+ ╱ ╲</pre>
1210
+ <pre class="rf rf2"> (\\(\\
1211
+ ( ˘ω˘ )
1212
+ (っ っ)
1213
+ │ │</pre>
1214
+ <pre class="rf rf3"> (\\(\\
1215
+ ( ˘ω˘ )
1216
+ (っ っ)
1217
+ ╲ ╱</pre>
1218
+ </div>
1219
+ <div class="h-brand">
1220
+ <span class="brand-name">◆ caddis</span>
1221
+ <span class="brand-tag">usage review</span>
1222
+ </div>
1223
+ </div>
1224
+ </div>
1225
+
1226
+ <div>
1227
+ <h1>/usage-review</h1>
1228
+ <div class="subtitle">Last {days} days &middot; {ws} &ndash; {we} &middot; {sessions} session{"s" if sessions != 1 else ""}</div>
1229
+ <div class="summary-bar">
1230
+ <div class="summary-dot {dot_cls}"></div>
1231
+ <span>{summary_text}</span>
1232
+ </div>
1233
+ </div>
1234
+
1235
+ <div class="pills">{pills}</div>
1236
+
1237
+ <div class="charts-label">Usage charts</div>
1238
+ <div class="charts">{charts_html}</div>
1239
+
1240
+ {action_section}
1241
+ {advisory_section}
1242
+
1243
+ <footer>Generated {generated} &middot; <code>{log_path}</code> + session transcripts &middot; Estimates only — all data stays local. &middot; <a href="https://github.com/saajunaid/caddis-plugin" style="color:var(--ink-4)">caddis</a></footer>
1244
+ </div>
1245
+ </body>
1246
+ </html>"""
1247
+
1248
+ # ── main ───────────────────────────────────────────────────────────────────────
1249
+
1250
+ def main() -> None:
1251
+ ap = argparse.ArgumentParser(
1252
+ description="caddis /usage-review: usage analysis + harness recommendations"
1253
+ )
1254
+ ap.add_argument("--days", type=int, default=7, help="Analysis window in days (default 7)")
1255
+ ap.add_argument("--cwd", default=os.getcwd(), help="Project root (default: cwd)")
1256
+ ap.add_argument("--no-html", action="store_true", help="Skip HTML dashboard generation")
1257
+ ap.add_argument(
1258
+ "--output-dir", default=None,
1259
+ help="Directory for HTML output relative to cwd. Defaults to <artifact-dir>/reviews.",
1260
+ )
1261
+ args = ap.parse_args()
1262
+
1263
+ cwd = _repo_root(os.path.abspath(args.cwd))
1264
+ days = args.days
1265
+ now = datetime.now(timezone.utc)
1266
+ ws = (now - timedelta(days=days)).strftime("%Y-%m-%d")
1267
+ we = now.strftime("%Y-%m-%d")
1268
+
1269
+ # Resolve output directory: the HTML dashboard lives under <artifact-dir>/reviews — the dir
1270
+ # this repo actually uses, so an unmigrated repo doesn't sprout a second one.
1271
+ if args.output_dir is not None:
1272
+ out_subdir = args.output_dir
1273
+ else:
1274
+ out_subdir = os.path.join(artifact_root(cwd).name, "reviews")
1275
+
1276
+ # Load data
1277
+ current_sessions, prev_sessions = load_usage_log(cwd, days)
1278
+
1279
+ if not current_sessions:
1280
+ print(
1281
+ f"# /usage-review\n\n"
1282
+ f"No sessions found in the last {days} days.\n"
1283
+ f"Run a few sessions to build up data, then re-run `/usage-review`.\n\n"
1284
+ f"(Data file: {_usage_log_path(cwd)})"
1285
+ )
1286
+ return
1287
+
1288
+ session_ids = [s.get("_session_id", "") for s in current_sessions if s.get("_session_id")]
1289
+ transcripts = load_transcripts(cwd, session_ids)
1290
+ metrics = compute_metrics(current_sessions, transcripts)
1291
+ prev_metrics = compute_metrics(prev_sessions, {})
1292
+ agents = load_agent_config(cwd)
1293
+ cmd_roster = load_command_roster(cwd)
1294
+ findings = run_rules(metrics, prev_metrics, agents, cmd_roster=cmd_roster)
1295
+
1296
+ # Markdown to stdout
1297
+ print(render_markdown(metrics, findings, days, ws, we))
1298
+
1299
+ # HTML dashboard
1300
+ if not args.no_html:
1301
+ generated = now.strftime("%Y-%m-%d %H:%M UTC")
1302
+ html = render_html(metrics, findings, days, ws, we, generated,
1303
+ log_path=os.path.relpath(_usage_log_path(cwd), cwd).replace(os.sep, "/"))
1304
+ out_dir = os.path.join(cwd, out_subdir)
1305
+ os.makedirs(out_dir, exist_ok=True)
1306
+ html_path = os.path.join(out_dir, "usage-review.html")
1307
+ with open(html_path, "w", encoding="utf-8") as fh:
1308
+ fh.write(html)
1309
+ print(f"\n→ HTML dashboard: {html_path}")
1310
+
1311
+ # Update last-review timestamp — in the repo's artifact dir so inject_relay.py finds it
1312
+ caddis_dir = str(artifact_root(cwd))
1313
+ os.makedirs(caddis_dir, exist_ok=True)
1314
+ stamp_path = os.path.join(caddis_dir, ".last-usage-review")
1315
+ try:
1316
+ with open(stamp_path, "w", encoding="utf-8") as fh:
1317
+ fh.write(now.isoformat(timespec="seconds"))
1318
+ except Exception:
1319
+ pass
1320
+
1321
+
1322
+ if __name__ == "__main__":
1323
+ main()