In this article
In my role as a Sr. Product Manager, AI tools let me contribute well beyond my role. This has been genuinely exciting. But work produced outside your craft has hidden costs. AI makes it easy to produce something fast that looks good. But looking good isn't the bar. The people who own that craft hold a standard they'd be proud to ship themselves, and they inherit the work of pulling your output up to it.
AI is a mirror: it reflects your skills back at you. And now that tokens are getting priced, there's an economic version of the same point. The simplest read on whether you're spending them effectively is how much of your agent's work happens inside your own craft. That's not "stay in your lane." Crossing domains for context and learning is healthy; handing off deliverables you can't judge is where it leaks.
All of this will keep evolving as AI does, but one thing holds: we each have real strengths, and as long as we're in the loop, they matter.
How I got here
Three years ago, the best tool I had for showing a customer an idea was a slide deck. Then v0 arrived, and I could get on a call with a clickable prototype instead of a picture of one. That felt like a superpower.
But the possibilities kept improving. I can make the v0 prototypes look more like our product. Then, with Claude Code, they could just be built in our real codebase, running locally, on our real components. It wasn’t long before the lowest friction thing was to just ship the code.
There was a second thrill underneath the speed. That feeling of empowerment, a seat at the table, the ability to use the idea of "code wins arguments" to push my ideas forward that otherwise may have received more scrutiny. Being able to share a working implementation forces attention in a way that a doc or ppt deck can never get.
Anyone who's used agentic tools to creep outside their domain knows this arc. Every step felt like progress. And there’s little incentive to slow down because the outputs come quickly, look good (enough) and seem to progress the team as a whole. The answer to "mind if I use Claude to take a crack at this?" is usually an indifferent “sure, why not?”.
But after a couple months, I think I’m seeing the costs it’s had on my day-to-day workflows (and likely the work I’m inadvertently handing to others).
The road to vibing has no signposts
Every step on that path is locally reasonable, and there's no marker telling you when you've crossed from discovery into something else.
Every step is locally reasonable. There's no signpost that says "you have left discovery."
The logic that carries you down the slope is real product logic. Speed to feedback for me is the prize, and live behavior from customers using our product beats a demo reaction. Why show a customer a prototype when you could flag the real thing into their account? I wasn't being reckless when I followed that reasoning, I just wanted to improve the feedback loops.
But a few things started nagging at me.
My time was leaking. I'd look up from hours of wrangling code in a part of the system I didn't understand and realize the agent had made the work possible without making it a good use of me.
What I shipped wasn't finished. I see the part of a PR above the water: it exists, it runs. What I can't see, because I've never lived it, is everything below: the review burden on an engineer who has to understand code I don't fully understand myself, the rework, the on-call rotation that inherits it. Along with the code, I was pushing cognitive load (downstream to a colleague). My PR felt like a contribution to me. To the reviewing engineer, it was unpriced work.
Most of shipping happens after the merge, and none of it happens to the person who vibe-coded it.
But this realization didn’t really sink in until being on the receiving end.
AI is a mirror
There's a motif I've picked up from Ben Thompson and others: AI is a mirror. It reflects your own skills back at you. Like a mirror, it also projects them out for others. Your agent is roughly as good as you are at the thing you're pointing it at, because the output is bounded by your ability to steer, push back, and recognize when it's wrong.
I know this because I've also lived the receiving end. When someone who isn't a PM sends me AI-generated "product" output, I can tell within seconds. It’s got the telltale confident assumptions and lists of features where problems, roadblocks or opportunities should be. What makes it frustrating is that the assumptions and requests are genuinely plausible. If it felt wrong, it'd be easy to spot and hand back. But because the ideas are plausible, I have to unpack the bias and uncover what the root problem in the source material actually was. That output made things more complicated. I'd have gotten closer to my bar if I'd been the one steering the agent through the source feedback material.
And on reflection, that's exactly what my PRs look like to an engineer. I can smell when PM work is off. I cannot smell when Go code is gnarly. The agent hides the issues and gaps with code that looks correct enough.
And it generalizes. A PM doing engineering produces code that looks done and reads like risk. An engineer, sales, or customer success roles doing product produces feature lists that look like strategy, minus everything that matters: which problem, what was cut, what the customer meant. Anyone doing design produces screens that look good, which is the trap, because the surface is most of what non-designers can perceive. The hierarchy, the coherence, the states nobody screenshots are underwater.
AI raises the floor of what you can produce outside your domain, but it also has the potential to raise the ceiling inside it. Cross-domain output is plausible. In-domain output can be great. And plausible-but-not-good is the most expensive kind of artifact, because someone qualified has to take it apart.
The token subsidy is ending
There's an economic reason this question is getting sharper right now.
For the past couple of years we've been living in a token surplus. Like cloud credits in the 2010s or subsidized Uber rides, usage was priced below its real value, so the rational move was to max out. Throw tokens at everything, including everything outside your lane. I'd defend that era. Token maxing was how we found out what these tools could do, and doing the coding myself is how I learned where the walls are. You can't see the iceberg from a doc.
But subsidies end. Token costs are becoming real line items, and the leaderboard question is shifting from "who's using AI the most" to "who's using it most effectively."
When usage was free, exploration was the strategy. When usage is priced, allocation is the strategy.
This is where the two frames snap together. When tokens are free, a PM spending a week of them on engineering work costs nothing visible. When they're priced, it becomes an allocation question, and the mirror gives you the answer: tokens compound inside your domain and leak outside it. The same thousand tokens make me meaningfully better at problem framing, or a mediocre, expensive junior engineer.
There's also a cost that never shows up in the PR. Every hour I spent wrangling code outside my expertise was an hour I didn't spend on the compounding stuff, and it's simply less efficient for me to grind on engineering tasks when the same hours pointed at the problem space go so much further. Used well, AI could 10x my PM output if I stay focused there. Every time I slipped into the next steps without the people who own them, I was trading that multiplier away only to move into areas outside of my comfort zone.
Measuring token effectiveness task-by-task will be hard, and I'm skeptical anyone gets a clean ROI metric soon. But there's a first cut that I’d argue is an early indicator: the category of work you’re using AI to accomplish. Is this person spending tokens inside the craft they've built judgment in, or outside it? That one question probably explains more variance in AI effectiveness than any prompt technique or model choice.
The danger quadrant is crossing domains and then handing off the artifact
The mirror test
Before pointing an agent at a task, ask: am I the person whose judgment makes this output good, or just the person whose prompting makes it exist?
Three commitments follow.
Spend tokens where you have taste. My ceiling with AI is the problem space: sharper discovery, better-framed problems, faster synthesis of customer evidence. That's where my agent is genuinely good, because I can catch it being wrong.
Cross domains for context, not deliverables. Prototypes and technical discovery are in. But the output should be understanding I carry back to my own craft, not artifacts someone else has to own. The moment a prototype is "pretty close, might as well flip the switch," it's leaked. Hard constraints help: prototype spaces that structurally can't merge.
Feed context to the people who own the craft. If I want engineering to go faster, the highest-leverage move is loading the engineer, and increasingly the engineer's agent, with the problem, the evidence, the constraints, what we cut and why. Their agent reflects their skill, and it does its best work when the inputs carry mine.
That last one resolves the equal seat I was chasing. What I actually wanted was to be heard at the speed of code, and context travels that fast now too.
The binding constraint is the judgment of whoever's steering, and judgment doesn't transfer through a prompt. The tools will keep making it easier to produce plausible work outside your craft, which makes the discipline of not doing so more valuable, not less.
The companies that win the next phase will be the ones that figure out fastest where their tokens compound.
So how am I doing on the mirror test?
Now to be a bit vulnerable and have a little fun, I want to share my own results of the mirror test. Below is my own 2x2 report of what my work has looked like in the last 30 days: where I've been compounding and sharpening versus where I've been bridging and leaking in my own usage of AI tools.
Looking at my own numbers, I’d initially thought it would be worse. Turned out most of my work landed right where it should, and the share that leaked into engineering came in lower than I'd have guessed.
But I definitely felt that 18% leaking more than this represents. The stretches where I leaked were the same stretches I could feel myself losing a step in my own work. I really still feel those detours into code drained me in a way that 18% doesn't capture.
So what I'm taking away is more of a feel than a number. When I notice I'm slipping behind on the work only I can do, it's usually the same week I went deep in someone else's lane. That's the signal I'm going to trust going forward, more than any target percentage. Going forward, I’ve given my own Claude Code instructions to call me out based on this framing to simply make me more aware of the times I begin to drift too far beyond my area of expertise.
Want to make your own? Here’s the skill, simply give it to your Claude Code and you can see your own version of this report:
name: token-mirror
description: Generate a "Token Mirror" report — classify your last 30 days of Claude Code usage into compounding / sharpening / bridging / leaking quadrants based on whether your tokens were spent inside or outside your craft. Use when the user asks "where are my tokens going," "run my token mirror," "am I passing the mirror test," or wants a personal AI-usage allocation report.
# Token Mirror
Everything runs locally against the transcripts Claude Code already stores on this machine
(`~/.claude/projects/*/*.jsonl`). Nothing is uploaded anywhere. The output is a single
`report.html` the user can open, print to PDF, or share.
**The frame** (from [Spend Your Tokens Where You Have Taste](https://www.glencornell.com/thoughts/spend-your-tokens-where-you-have-taste)):
tokens compound inside your domain and leak outside it. Every session lands in a 2x2 —
*inside vs outside your craft* × *output is context/learning vs a deliverable someone must own*:
- **compounding** — inside craft, shipped deliverable (an engineer's PR, a PM's shaping doc)
- **sharpening** — inside craft, context: research, reading, building your own tooling
- **bridging** — outside craft, context: prototypes and technical discovery that stay throwaway
- **leaking** — outside craft, handed-off deliverable: the artifact someone qualified must now own
The danger quadrant isn't crossing domains. It's crossing domains *and handing off the artifact.*
## Step 1 — Interview (keep it to ~3 questions)
You cannot classify sessions without knowing whose craft this is. Ask, or confirm what you
can infer from their setup:
1. **What's your craft?** (PM, engineer, designer, CSM, marketer...)
2. **Which outputs are *yours*?** Map to paths/repos/tools: for a PM that might be docs,
notes, Linear, analysis; for an engineer their team's repos; for a designer Figma + specs.
3. **Which areas are *other people's* crafts that you sometimes build in?** For a PM: code
repos. For an engineer: product specs, design mockups. Also: any personal projects to
bucket separately, and what "handing off" looks like (usually `gh pr create` / `git push`
to shared repos; for non-code crafts it may be posting docs others must own).
Then translate the answers into the `config.json` patterns in Step 2. Show the user the
config before running and let them correct it.
## Step 2 — Materialize the tool
Create a working directory `~/token-mirror/`, write the script below as `token_mirror.py`,
and write a `config.json` shaped like this (this example is a PM at a company with code in
`~/dev/`; adapt every regex to the interview):
```json
{
"user": "Sam",
"craft": "product management",
"patterns": {
"craft_work": "/notes/|shaping|linear|prd|brief|notion|mixpanel|amplitude|granola",
"craft_tooling": "/\\.claude/|skills/|memory/|CLAUDE\\.md|hooks/",
"outside_build": "/dev/|/src/|\\.go\\b|\\.tsx?\\b|company-repo-names",
"personal": "/side-project-dirs|portfolio-site"
},
"mcp_patterns": { "craft_work": "linear|notion|slack_send" },
"handoff": "gh pr create|git push.*origin",
"overrides": {},
"verdict": ""
}
```
Role inversion note: for an **engineer**, `craft_work` is their team's repos and a handoff
PR is *compounding*, while `outside_build` is PM/design artifact production (docs pushed to
PMs, generated specs). Set the patterns so that "outside_build + handoff" always means
*a deliverable someone else's craft must own*.
## Step 3 — Parse
```
cd ~/token-mirror && python3 token_mirror.py parse
```
Sanity-check the printed digest: total sessions > 0, the dedupe note (resumed sessions copy
history; the script counts each message UUID once), and that the top sessions look like real
work the user recognizes.
## Step 4 — Editorial review (do not skip; this is where the report earns trust)
Heuristics misclassify edge cases, and the top ~15 sessions usually carry most of the tokens.
Read the digest's top sessions (topic + signal hits + handoff flag) and judge each by the
**artifact-ownership rule**: where did the session's output land, and whose craft must own
it? A prototype that died after a demo is bridging even if it touched the codebase; a "quick
doc" handed to a PM to untangle is leaking even though it's just words. Write corrections
into `config.json` → `overrides` as `{"<8-char session id>": "<category>"}` using categories:
`craft_work`, `craft_tooling`, `research`, `outside_local`, `outside_shipped`, `personal`.
## Step 5 — Verdict and report
Write a 2–3 sentence honest verdict into `config.json` → `verdict` (lead with the strongest
number; name the quadrant to move; no flattery — the report is a mirror, not a trophy). Then:
```
python3 token_mirror.py report
```
Open `report.html`. Offer a PDF:
```
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
--no-pdf-header-footer --print-to-pdf=token-mirror.pdf report.html
```
Before the user shares the report anywhere, remind them it contains their session topics —
worth a skim for anything sensitive.
## Caveats to include honestly
Effort tokens = model output + uncached input. Cache writes and reads are excluded — they are
dominated by conversation history being re-cached/re-read, which inflates long-lived sessions
rather than measuring new work. Claude Code only — claude.ai and other tools are invisible. Classification is
heuristic plus manual review of top sessions; the long tail is ±a few points. Active time
sums message gaps under 10 minutes and underestimates wall-clock.
## The script
```python
#!/usr/bin/env python3
"""Token Mirror: classify 30 days of Claude Code sessions into craft quadrants.
Usage: python3 token_mirror.py parse | report (reads config.json in same dir)"""
import json, os, re, glob, sys, html
from datetime import datetime, timedelta, timezone, date
from collections import defaultdict
DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.expanduser("~/.claude/projects")
CUTOFF = datetime.now(timezone.utc) - timedelta(days=30)
CFG = json.load(open(os.path.join(DIR, "config.json")))
RX = {k: re.compile(v, re.I) for k, v in CFG["patterns"].items()}
MCP_RX = {k: re.compile(v, re.I) for k, v in CFG.get("mcp_patterns", {}).items()}
HANDOFF = re.compile(CFG.get("handoff", "gh pr create|git push.*origin"), re.I)
CAT_QUAD = {"craft_work": "compounding", "craft_tooling": "sharpening",
"research": "sharpening", "outside_local": "bridging",
"outside_shipped": "leaking", "personal": "personal", "unclassified": "other"}
CAT_LABEL = {"craft_work": "Craft work (shipped)", "craft_tooling": "Own tooling & setup",
"research": "Research & reading", "outside_local": "Cross-craft builds (kept local)",
"outside_shipped": "Cross-craft work (handed off)", "personal": "Personal projects",
"unclassified": "Unclassified"}
QUAD_META = {
"compounding": ("#2c7a4b", "Inside the craft, shipped deliverables"),
"sharpening": ("#1f6f6b", "Inside the craft, building context: research, reading, own tooling"),
"bridging": ("#b07a23", "Outside the craft, for learning: prototypes and discovery that stayed throwaway"),
"leaking": ("#a01414", "Outside the craft, handed off: deliverables someone else's craft must own"),
"personal": ("#7a7a7a", "Own projects, owned end to end"),
"other": ("#b9b1a4", "Short or ambiguous sessions with no strong signal")}
QUAD_ORDER = ["compounding", "sharpening", "bridging", "leaking", "personal", "other"]
def text_of(c):
if isinstance(c, str): return c
if isinstance(c, list):
return " ".join(x.get("text", "") for x in c if isinstance(x, dict) and x.get("type") == "text")
return ""
def parse_file(path, seen):
s = {"first_ts": None, "last_ts": None, "out": 0, "fin": 0, "cwrite": 0, "cread": 0,
"msgs": 0, "hits": defaultdict(int), "handoff": False, "edited_outside": False,
"topic": None, "active": 0.0}
prev = None
for line in open(path, errors="replace"):
try: d = json.loads(line)
except Exception: continue
m = d.get("message") or {}
role = m.get("role") or d.get("type")
content = m.get("content")
# topic = the thread's real first prompt, even if that line is deduped
# (continuation files copy history; the copied first prompt names the thread)
if role == "user" and not d.get("isSidechain") and s["topic"] is None:
t = text_of(content).strip()
if t and not t.startswith("<") and len(t) > 15:
s["topic"] = re.sub(r"\s+", " ", t)[:160]
# resumed/branched sessions copy prior history into new files;
# count every message exactly once, in the earliest file it appears
uid = d.get("uuid")
if uid:
if uid in seen: continue
seen.add(uid)
ts = None
if d.get("timestamp"):
try: ts = datetime.fromisoformat(d["timestamp"].replace("Z", "+00:00"))
except Exception: pass
if ts and ts < CUTOFF: continue
if ts:
if s["first_ts"] is None or ts < s["first_ts"]: s["first_ts"] = ts
if s["last_ts"] is None or ts > s["last_ts"]: s["last_ts"] = ts
if prev is not None:
g = (ts - prev).total_seconds() / 60
if 0 <= g <= 10: s["active"] += g
prev = ts
u = m.get("usage")
if u:
s["out"] += u.get("output_tokens", 0) or 0
s["fin"] += u.get("input_tokens", 0) or 0
s["cwrite"] += u.get("cache_creation_input_tokens", 0) or 0
s["cread"] += u.get("cache_read_input_tokens", 0) or 0
s["msgs"] += 1
if isinstance(content, list):
for c in content:
if not (isinstance(c, dict) and c.get("type") == "tool_use"): continue
inp = c.get("input") or {}
blob = " ".join(str(v)[:400] for k, v in inp.items()
if k in ("file_path", "command", "path", "prompt", "url", "id"))
for b, rx in RX.items():
if rx.search(blob): s["hits"][b] += 1
for b, rx in MCP_RX.items():
if rx.search(c.get("name", "")): s["hits"][b] += 1
if HANDOFF.search(blob): s["handoff"] = True
if c.get("name") in ("Edit", "Write") and RX.get("outside_build") and RX["outside_build"].search(blob):
s["edited_outside"] = True
return s if s["msgs"] else None
def classify(s):
h = s["hits"]
core = max(h.get("craft_work", 0), h.get("outside_build", 0), h.get("craft_tooling", 0))
if h.get("personal", 0) > core: return "personal"
if h.get("outside_build", 0) > 0 and (s["edited_outside"] or s["handoff"]):
return "outside_shipped" if s["handoff"] else "outside_local"
if h.get("outside_build", 0) > h.get("craft_work", 0): return "research"
if h.get("craft_tooling", 0) > h.get("craft_work", 0): return "craft_tooling"
if h.get("craft_work", 0) > 0: return "craft_work"
return "unclassified"
def cmd_parse():
files = sorted((f for f in glob.glob(os.path.join(ROOT, "*", "*.jsonl"))
if os.path.getmtime(f) >= CUTOFF.timestamp()), key=os.path.getmtime)
rows, seen = [], set()
for f in files:
s = parse_file(f, seen)
if not s: continue
span_days = 0
if s["first_ts"] and s["last_ts"]:
span_days = (s["last_ts"].date() - s["first_ts"].date()).days
rows.append({"id": os.path.basename(f)[:8],
"date": s["first_ts"].date().isoformat() if s["first_ts"] else None,
"end_date": s["last_ts"].date().isoformat() if s["last_ts"] else None,
"span_days": span_days,
"topic": s["topic"] or "(no prompt captured)", "category": classify(s),
# effort = output + uncached input; cache writes tracked separately
# (they're mostly history re-caching and inflate long-lived sessions)
"effort": s["out"] + s["fin"], "out": s["out"],
"cwrite": s["cwrite"], "cread": s["cread"],
"active_min": round(s["active"], 1), "hits": dict(s["hits"]),
"handoff": s["handoff"], "edited_outside": s["edited_outside"]})
rows.sort(key=lambda r: -r["effort"])
json.dump(rows, open(os.path.join(DIR, "sessions.json"), "w"), indent=1)
total = sum(r["effort"] for r in rows)
print(f"sessions: {len(rows)} | effort tokens (output + uncached input): {total:,}")
print(f"(excluded: {sum(r['cwrite'] for r in rows)/1e6:.0f}M cache writes, "
f"{sum(r['cread'] for r in rows)/1e9:.1f}B cache reads)")
agg = defaultdict(int)
for r in rows: agg[r["category"]] += r["effort"]
for c, v in sorted(agg.items(), key=lambda kv: -kv[1]):
print(f" {c:16s} {v:>14,} ({100*v/max(total,1):.1f}%)")
print("\ntop 15 (review these, then set overrides in config.json):")
for r in rows[:15]:
span = f" [{r['date']}→{r['end_date']}]" if r["span_days"] else ""
print(f" {r['id']} {r['date']} {r['effort']:>11,} {r['category']:16s} "
f"handoff={r['handoff']}{span} | {r['topic'][:60]}")
def cmd_report():
rows = json.load(open(os.path.join(DIR, "sessions.json")))
for r in rows:
r["category"] = CFG.get("overrides", {}).get(r["id"], r["category"])
r["quadrant"] = CAT_QUAD[r["category"]]
total = sum(r["effort"] for r in rows)
work = sum(r["effort"] for r in rows if r["quadrant"] not in ("personal", "other"))
bq = defaultdict(lambda: {"tok": 0, "n": 0})
bc = defaultdict(lambda: {"tok": 0, "n": 0, "min": 0.0})
bd = defaultdict(lambda: defaultdict(int))
for r in rows:
bq[r["quadrant"]]["tok"] += r["effort"]; bq[r["quadrant"]]["n"] += 1
bc[r["category"]]["tok"] += r["effort"]; bc[r["category"]]["n"] += 1; bc[r["category"]]["min"] += r["active_min"]
if r["date"]: bd[r["date"]][r["quadrant"]] += r["effort"]
fmt = lambda t: f"{t/1e6:.1f}M"
pct = lambda t, b: f"{100*t/max(b,1):.0f}%"
dates = sorted(bd)
d0, d1 = date.fromisoformat(dates[0]), date.fromisoformat(dates[-1])
days = [(d0 + timedelta(days=i)).isoformat() for i in range((d1 - d0).days + 1)]
W, H, PAD = 1080, 260, 36
mx = max((sum(v.values()) for v in bd.values()), default=1)
bars = []
for i, day in enumerate(days):
x = PAD + i * (W - 2 * PAD) / len(days); y = H - 30
for q in QUAD_ORDER:
v = bd.get(day, {}).get(q, 0)
if not v: continue
hh = (H - 60) * v / mx
bars.append(f'<rect x="{x:.1f}" y="{y-hh:.1f}" width="{(W-2*PAD)/len(days)*0.72:.1f}" height="{hh:.1f}" fill="{QUAD_META[q][0]}" opacity=".9"/>')
y -= hh
if date.fromisoformat(day).weekday() == 0:
bars.append(f'<text x="{x:.1f}" y="{H-10}" font-size="11" fill="#998f7f">{day[5:]}</text>')
daily = f'<svg viewBox="0 0 {W} {H}" xmlns="http://www.w3.org/2000/svg">{"".join(bars)}</svg>'
def cell(x, y, q):
col, tok = QUAD_META[q][0], bq[q]["tok"]
return (f'<text x="{x}" y="{y}" text-anchor="middle" font-size="34" fill="{col}" class="cv">{q}</text>'
f'<text x="{x}" y="{y+44}" text-anchor="middle" font-size="40" font-weight="700" fill="{col}">{pct(tok, work)}</text>'
f'<text x="{x}" y="{y+72}" text-anchor="middle" font-size="15" fill="#8a8174">{fmt(tok)} tokens · {bq[q]["n"]} sessions</text>')
quad = (f'<svg viewBox="0 0 760 480" xmlns="http://www.w3.org/2000/svg">'
f'<line x1="100" y1="240" x2="660" y2="240" stroke="#998f7f" stroke-width="1.5"/>'
f'<line x1="380" y1="60" x2="380" y2="430" stroke="#998f7f" stroke-width="1.5"/>'
f'<text x="380" y="40" text-anchor="middle" font-size="14" fill="#8a8174">output: a deliverable someone must own</text>'
f'<text x="380" y="460" text-anchor="middle" font-size="14" fill="#8a8174">output: context & learning</text>'
f'<text x="92" y="244" text-anchor="end" font-size="14" fill="#8a8174">inside craft</text>'
f'<text x="668" y="244" text-anchor="start" font-size="14" fill="#8a8174">outside craft</text>'
f'{cell(240,140,"compounding")}{cell(520,140,"leaking")}{cell(240,340,"sharpening")}{cell(520,340,"bridging")}</svg>')
def trow(r):
col = QUAD_META[r["quadrant"]][0]
when = r["date"] if not r["span_days"] else f'{r["date"]}→{r["end_date"][5:]}'
return (f'<tr><td class="mono">{when}</td><td>{html.escape(r["topic"][:96])}</td>'
f'<td><span class="chip" style="background:{col}18;color:{col}">{CAT_LABEL[r["category"]]}</span></td>'
f'<td class="mono num">{fmt(r["effort"])}</td><td class="mono num">{r["active_min"]/60:.1f}h</td></tr>')
top = "".join(trow(r) for r in rows[:15])
leak = "".join(trow(r) for r in rows if r["quadrant"] == "leaking") or "<tr><td>none 🎉</td></tr>"
mxc = max(v["tok"] for v in bc.values())
catbars = "".join(
f'<div class="catrow"><div class="catlabel">{CAT_LABEL[c]}</div>'
f'<div class="cattrack"><div class="catbar" style="width:{100*v["tok"]/mxc:.1f}%;background:{QUAD_META[CAT_QUAD[c]][0]}"></div></div>'
f'<div class="catval mono">{fmt(v["tok"])} · {pct(v["tok"], total)} · {v["n"]} sess · {v["min"]/60:.0f}h</div></div>'
for c, v in sorted(bc.items(), key=lambda kv: -kv[1]["tok"]))
legend = "".join(f'<div class="leg"><span class="dot" style="background:{QUAD_META[q][0]}"></span>'
f'<b>{q}</b><span class="legdesc">{QUAD_META[q][1]}</span></div>' for q in QUAD_ORDER)
verdict = CFG.get("verdict") or "(write a verdict into config.json and re-run)"
active_h = sum(r["active_min"] for r in rows) / 60
out = f"""<!doctype html><html><head><meta charset="utf-8">
<title>The Token Mirror — {CFG.get('user','')}</title>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600&display=swap" rel="stylesheet">
<style>
body{{font-family:-apple-system,'Inter',sans-serif;background:#faf7f2;color:#2e2a24;margin:0}}
.wrap{{max-width:1080px;margin:0 auto;padding:48px 32px 80px}}
h1{{font-family:'Caveat',cursive;font-size:52px;margin:0}}
h2{{font-size:15px;text-transform:uppercase;letter-spacing:.12em;color:#b06a23;font-family:ui-monospace,monospace;margin:56px 0 16px}}
.sub{{color:#8a8174;margin-top:6px;font-size:15px}}
.stats{{display:flex;gap:48px;margin:36px 0 8px;flex-wrap:wrap}}
.stat .n{{font-size:34px;font-weight:700}} .stat .l{{font-size:13px;color:#8a8174}}
.mono{{font-family:ui-monospace,monospace;font-size:13px}} .num{{text-align:right;white-space:nowrap}}
table{{width:100%;border-collapse:collapse;font-size:14px}}
td{{padding:8px 10px 8px 0;border-bottom:1px solid #ece5da;vertical-align:top}}
.chip{{font-size:12px;padding:2px 8px;border-radius:99px;white-space:nowrap}}
.catrow{{display:flex;align-items:center;gap:14px;margin:7px 0}} .catlabel{{width:210px;font-size:14px}}
.cattrack{{flex:1;background:#efe9df;border-radius:6px;height:18px}} .catbar{{height:100%;border-radius:6px}}
.catval{{width:280px;color:#8a8174}}
.leg{{display:flex;gap:10px;align-items:baseline;margin:5px 0;font-size:14px}}
.dot{{width:11px;height:11px;border-radius:99px;display:inline-block;flex-shrink:0}} .legdesc{{color:#8a8174}}
.verdict{{background:#fff;border-left:4px solid #b06a23;padding:18px 22px;border-radius:0 10px 10px 0;font-size:16px;line-height:1.6}}
.caveats{{font-size:13px;color:#8a8174;line-height:1.6}} .cv{{font-family:'Caveat',cursive}}
</style></head><body><div class="wrap">
<h1>The Token Mirror</h1>
<div class="sub">Where {CFG.get('user','my')} Claude Code tokens went, {dates[0]} → {dates[-1]} · craft: {CFG.get('craft','')} · {len(rows)} sessions, local transcripts only</div>
<div class="stats">
<div class="stat"><div class="n">{total/1e6:.0f}M</div><div class="l">effort tokens (output + uncached input)</div></div>
<div class="stat"><div class="n">{len(rows)}</div><div class="l">sessions</div></div>
<div class="stat"><div class="n">{active_h:.0f}h</div><div class="l">session-active hours (incl. autonomous runs)</div></div>
<div class="stat"><div class="n">{pct(bq['leaking']['tok'], work)}</div><div class="l">of work tokens in the leak quadrant</div></div>
</div>
<h2>// the 2x2, with receipts</h2>{quad}{legend}
<p class="sub">Personal projects ({fmt(bq['personal']['tok'])}) and unclassified ({fmt(bq['other']['tok'])}) sit outside the work quadrants.</p>
<h2>// the verdict</h2><div class="verdict">{verdict}</div>
<h2>// tokens by day</h2>{daily}
<h2>// where the tokens went</h2>{catbars}
<h2>// top 15 sessions by effort</h2><table>{top}</table>
<h2>// the leak report — cross-craft deliverables handed off</h2><table>{leak}</table>
<h2>// caveats</h2><div class="caveats">
Effort tokens = model output + uncached input. Cache <i>writes</i> ({sum(r['cwrite'] for r in rows)/1e6:.0f}M) and cache <i>reads</i>
({sum(r['cread'] for r in rows)/1e9:.1f}B) are excluded: they're dominated by conversation history being re-cached and re-read,
which inflates long-lived sessions rather than measuring new work. Covers Claude Code only. Resumed sessions deduplicated
by message UUID; multi-day threads show a date range and their hours span the whole thread, including autonomous agent runs.
Classification is heuristic on file paths, tool calls, and handoff signals, with manual review of top sessions; boundary cases
follow the artifact-ownership rule. Method from
<a href="https://www.glencornell.com/thoughts/spend-your-tokens-where-you-have-taste">Spend Your Tokens Where You Have Taste</a>.
</div></div></body></html>"""
open(os.path.join(DIR, "report.html"), "w").write(out)
print("wrote report.html")
print(f"work split: " + " | ".join(f"{q} {pct(bq[q]['tok'], work)}" for q in QUAD_ORDER[:4]))
if __name__ == "__main__":
{"parse": cmd_parse, "report": cmd_report}.get(
sys.argv[1] if len(sys.argv) > 1 else "", lambda: print("usage: token_mirror.py parse|report"))()
```








