diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 60116ac..6c0d85b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ck", - "description": "Code Katz personas and workflows for Claude Code: the product-definition pipeline (/ck:opportunity, market-research, brief, prd, team, roadmap, architecture, brand-guide, design), /ck:panel (three lenses on three models), /ck:plan and /ck:start (a staged plan, then one named session per stage), /ck:do (one small task, quick and in the chat), /ck:next, /ck:claude-md (a project CLAUDE.md from ck's template), the memory gate (a proposal and your approval before any memory is saved), and the full persona roster, a complete cross-domain team, as ck: subagents and /ck: switch commands.", - "version": "0.1.18", + "description": "Code Katz personas and workflows for Claude Code: the product-definition pipeline (/ck:opportunity, market-research, brief, prd, team, roadmap, architecture, brand-guide, design), /ck:panel (three lenses on three models), /ck:plan and /ck:start (a staged plan, then one named session per stage), /ck:do (one small task, quick and in the chat), /ck:next, /ck:claude-md (a project CLAUDE.md from ck's template), /ck:cost (what a session cost, by lane and kind of token, also posted to its pull request at merge), the memory gate (a proposal and your approval before any memory is saved), and the full persona roster, a complete cross-domain team, as ck: subagents and /ck: switch commands.", + "version": "0.1.19", "author": { "name": "Code Katz" } } diff --git a/README.md b/README.md index 5304e6c..32d9573 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Phase one, first release in progress. What runs today: | `/ck:start [stage]` | Opens the plan's sessions and keeps its board. For a stage it offers a card that starts a session for that one job, named from the row, such as `[A31-P2-S1] [Akira Fable] Make the creature tables`; with nothing after it, it updates the State column from the sessions and their pull requests and offers every row that could start now. With `fix ` it offers a card for a session that fixes one fault in a stage that is done, named such as `[A31-P1-S5 fix] [Akira Fable] Mend the scan's natural 1`; the stage stays done. Every new session checks its own model before it works. You click each card | Built; not yet run on a real project | | `/ck:do ` | The quick path, all in the chat: a plan of a few lines (who does what), you say go, the right personas work in the background on their own tiers, a mockup comes back as a link when you ask for one first, and a build waits on its own branch until you say merge. No documents and no review pages | Built; tried in d20mob on 2026-10-27 | | `/ck:claude-md` | Writes or updates the "How we work" section of this project's `CLAUDE.md` from ck's template (`templates/how-we-work/`), tailored to the project: its lanes, main folder, records and secrets filled in, a rule that does not fit changed or left out. It sorts the project's memory into keep, retire, or move. You review the section on a page you can comment on; it is written on a branch with a draft pull request only when you approve. The rest of `CLAUDE.md` is left as it is, and nothing runs unless you type the command | Built; not yet run on a real project | +| `/ck:cost [history \| ]` | What this session has cost so far: a table with a row per lane (the main session, each persona, Claude Code's own background calls) and a column per kind of token (input, output, cache write, cache read), each cell in tokens and dollars, priced from Anthropic's pricing page and checked against Claude Code's own figure. Once the session has a pull request, a line after each answer shows the cost so far; at merge, a hook posts the table to the pull request and logs it, and `history` lists the logged merges | Built; tried on d20mob sessions | | The memory gate | Not a command: it runs on its own. Before a session saves anything to memory, it shows you the text, whether it belongs in memory or in the project's `CLAUDE.md` and why, and any memory or `CLAUDE.md` line that already says it or says the opposite. You pick save to memory, add to `CLAUDE.md` (written at once on a branch with a draft pull request), or don't save. A hook makes Claude Code ask you before every write to a memory folder, in auto mode too | Built; not yet run on a real project | | `/ck:panel ` | River, Toni, and Kai argue one question on three different models; a memo shows where they disagree | Built | | `/ck:prd [--panel]` | River drafts `docs/PRD.md` from the brief, a checker validates, River adds a premortem; with `--panel`, three specialists on three models challenge it first and River rewrites; you review on a page you can comment on | Built; drilled 2026-10-05 on a real project | diff --git a/ROADMAP.md b/ROADMAP.md index 86f76f8..48cee52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ck: Product Roadmap -> Maintained by [claude-roadmap-skill](https://github.com/code-katz/claude-roadmap-skill). Last updated: 2026-10-27. +> Maintained by [claude-roadmap-skill](https://github.com/code-katz/claude-roadmap-skill). Last updated: 2026-10-28. --- @@ -47,6 +47,7 @@ | 0.1.16 | 2026-10-27 | A shorter `/ck:start`; a bar in a code span stays in its table cell | Tag `v0.1.16` | | 0.1.17 | 2026-10-27 | `/ck:start` puts up chips again | Tag `v0.1.17`; stable and ck-next | | 0.1.18 | 2026-10-27 | A shorter `/ck:do` with no cost guess; its git steps in `scripts/do.sh` | Tag `v0.1.18`; ck-next | +| 0.1.19 | 2026-10-28 | `/ck:cost`: a session's tokens and dollars by lane and kind, a cost line once there is a pull request, the table posted at merge | Tag `v0.1.19`; ck-next | --- @@ -86,7 +87,7 @@ Dropped with ck2: its 0.2.0 release plan, which would have replaced ck in the `c |---|---|---|---| | 1 | **Try the memory gate in d20mob** | It is released and on stable, but not yet tried by hand | A save shows the proposal ending "Reply 1, 2 or 3", then the approval prompt | | 2 | **0.1.18: `/ck:do`** | It read 2,746 words, plus the 598-word roster, every time it was typed; Will chose to shorten it, not remove it (2026-10-27) | The decision made, and the release on ck-next | -| 3 | **0.1.19: a small cost script** | Shows what work costs by session, persona and model, so a release's saving can be measured | `/ck:cost` prints d20mob's last 7 days | +| 3 | **0.1.19: a small cost script** | Shows what work costs by session, persona and model, so a release's saving can be measured | Built in 0.1.19; done when a few merged pull requests carry the table and `/ck:cost history` compares them | | 4 | **Lanes: the model follows the task, not the persona** | A stage runs on its owner's tier, so Akira writes on Fable whether the job is a design call or a small edit, and each d20mob plan row needs a ruling to say otherwise. The V2 proposal (2026-10-13) designed lanes and ck2 built them (ck2 features, row 4); they are rebuilt from scratch | A plan's Who column names no model; in d20mob a stage runs its scout, builder and checker on the models one config file gives each lane, and its author on a model chosen for the job, with no per-row ruling | ### Tier 2: High Value, Plan for Next Sprint @@ -108,7 +109,7 @@ Dropped with ck2: its 0.2.0 release plan, which would have replaced ck in the `c ``` Now: try the memory gate in d20mob -Next: 0.1.18 (/ck:do) -> 0.1.19 (cost script) -> ck-next -> test -> stable +Next: 0.1.19 (cost script) on ck-next -> a few merges with the table -> stable Then: lanes -> ck-next -> a d20mob phase -> stable Later: the lean review's recommendations ``` @@ -136,6 +137,20 @@ Later: the lean review's recommendations ## Revision History +## [2026-10-28] 0.1.19: a small cost script + +**Change Type:** `add` +**Triggered by:** `planning-session` +**Items Affected:** Releases, Tier 1 item 3, Recommended Sequencing + +### What Changed +0.1.19 is added to Releases. `scripts/cost.py` (standard library) reads one session's transcripts, its subagents' and workflow agents' included, and prints " cost summary": a row per lane (the main session, each subagent by its type, and background, which is what Claude Code's own cost record counts beyond the transcripts) and a column per kind of token, each cell in tokens and dollars. Prices are fetched from Anthropic's pricing page at most once a day and saved in `~/.cache/ck/pricing.md`; a model the page does not list is priced as ?, never $0. Each request is priced on its own, with 5-minute and 1-hour cache writes at their own rates. The total is checked against Claude Code's own figure, and a line says when the two disagree. `/ck:cost` shows the table. Two hooks run the script with no model involved: after each answer, once the session has a pull request, one line with the cost so far; after `gh pr merge`, the table as a comment on the pull request and a line in `~/.claude/ck-cost.jsonl`, which `/ck:cost history` lists. On seven real sessions ck's total was within a few cents of Claude Code's, or the gap was explained. + +### Why +To tell whether a release such as the shorter `/ck:start` saved anything, and where the next saving is. Will chose live prices over a typed-in table, since a table goes stale: the pricing page already prices Sonnet 5.5 cache reads at $0.10 per million where Claude Code bills $0.20. He chose the table on the pull request at merge plus a line after each answer, since a session's end has no moment where the chat can show anything, and a table kept in the repository would make parallel sessions conflict. + +--- + ## [2026-10-27] 0.1.18: a shorter /ck:do **Change Type:** `add` diff --git a/hooks/hooks.json b/hooks/hooks.json index 842b178..03cb2f0 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "ck: persona usage log, a check that the old team tool is gone, and the memory gate", + "description": "ck: persona usage log, a check that the old team tool is gone, the memory gate, and the session cost (a line after each answer once there is a pull request, and the table on the pull request at merge)", "hooks": { "SubagentStart": [ { @@ -24,6 +24,21 @@ { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/memory-gate.py\" check" } ] } + ], + "PostToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/cost.py\" merge", "timeout": 60 } + ] + } + ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/cost.py\" stop", "timeout": 20 } + ] + } ] } } diff --git a/scripts/cost.py b/scripts/cost.py new file mode 100644 index 0000000..a68211d --- /dev/null +++ b/scripts/cost.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""cost.py: what one Claude Code session cost, in tokens and dollars, by lane and by kind of token. + +Usage: + cost.py [SESSION ID | TRANSCRIPT] the session's cost summary as a Markdown table; the default is the + newest session of the repository in the current folder (or a worktree) + cost.py history one row per merged pull request, from ~/.claude/ck-cost.jsonl + cost.py stop Stop hook: once the session has a pull request, one cost line + cost.py merge PostToolUse hook: after gh pr merge, posts the table to the pull + request and adds a line to ~/.claude/ck-cost.jsonl + +Lanes are the main session, each subagent by its type (ck: for a persona), and background: what +Claude Code's own record (the transcript's last cost-state) counts beyond the transcripts. Prices come +from Anthropic's pricing page, fetched at most once a day into ~/.cache/ck/pricing.md (CK_PRICING_FILE +names a copy to use instead). A model the page does not list, or a fast-mode request, is priced as ?, +never $0. Standard library only; prints no message text; never a traceback. +""" + +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +from collections import defaultdict +from pathlib import Path + +PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing.md" +CACHE = Path.home() / ".cache/ck/pricing.md" +LOG = Path.home() / ".claude/ck-cost.jsonl" +PROJECTS = Path.home() / ".claude/projects" +KINDS = ("input", "output", "write", "read") +FIELDS = ("inputTokens", "outputTokens", "cacheCreationInputTokens", "cacheReadInputTokens") +HEAD = "| Input | Output | Cache write | Cache read | Total |" + + +def parse(text): + """{model id: (input, 5m write, 1h write, cache read, output) in dollars per token} from the page's table.""" + prices, cols = {}, None + for line in text.splitlines(): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if "1h cache writes" in line: + want = ("Base input", "5m cache", "1h cache", "Cache hits", "Output") + cols = [next(i for i, c in enumerate(cells) if c.startswith(w)) for w in want] + elif cols and not line.startswith("|") and prices: + break + elif cols and cells[0].startswith("Claude"): + long = "over 100,000" in cells[0] + name = re.sub(r"\(.*", "", cells[0]).strip().lower() + try: + prices[re.sub(r"[ .]", "-", name) + (">100k" if long else "")] = tuple( + float(re.search(r"\$([\d.]+)", cells[i]).group(1)) / 1e6 for i in cols) + except (AttributeError, IndexError, ValueError): + pass + return prices + + +def pricing(): + """The prices, and a line saying where they came from.""" + path = Path(os.environ.get("CK_PRICING_FILE") or CACHE) + if "CK_PRICING_FILE" not in os.environ and (not path.exists() or time.time() - path.stat().st_mtime > 86400): + try: + request = urllib.request.Request(PRICING_URL, headers={"User-Agent": "ck-cost"}) + text = urllib.request.urlopen(request, timeout=10).read().decode() + if parse(text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + except Exception: + pass + try: + prices = parse(path.read_text()) + day = time.strftime("%Y-%m-%d", time.localtime(path.stat().st_mtime)) + except OSError: + prices = {} + if not prices: + return {}, "No prices: the pricing page could not be read and there is no saved copy, so every $ is ?." + return prices, f"Prices from {PRICING_URL}, as of {day}." + + +def priced(prices, model, t, per_request=True): + """Dollars per kind for one set of tokens, or None when it cannot be priced.""" + m = re.sub(r"-\d{8}$", "", re.sub(r"\[.*\]$", "", model or "")) + prompt = t["input"] + t["w5"] + t["w1"] + t["read"] + p = prices.get(m + ">100k") if per_request and prompt > 100000 else None + p = p or prices.get(m) + if not p or t.get("fast"): + return None + base, c5, c1, hit, out = p + return {"input": t["input"] * base, "output": t["output"] * out, "write": t["w5"] * c5 + t["w1"] * c1, + "read": t["read"] * hit} + + +def tokens(u): + w = u.get("cache_creation") if isinstance(u.get("cache_creation"), dict) else None + return {"input": u.get("input_tokens") or 0, "output": u.get("output_tokens") or 0, + "w5": (w.get("ephemeral_5m_input_tokens") or 0) if w else (u.get("cache_creation_input_tokens") or 0), + "w1": (w.get("ephemeral_1h_input_tokens") or 0) if w else 0, + "read": u.get("cache_read_input_tokens") or 0, "fast": u.get("speed") == "fast"} + + +def add(row, t, dollars): + for k in KINDS: + row[k][0] += t["w5"] + t["w1"] if k == "write" else t[k] + row[k][1] = None if dollars is None or row[k][1] is None else row[k][1] + dollars[k] + + +def read(transcript, prices): + """{(lane, model): {kind: [tokens, dollars or None]}}, the title, Claude Code's record, the pull request link.""" + rows = defaultdict(lambda: {k: [0, 0.0] for k in KINDS}) + names, state, pr, seen = {}, None, None, set() + base = Path(transcript) + files = [(base, "main")] + for f in sorted(base.with_suffix("").glob("subagents/**/*.jsonl")): + try: + lane = json.loads(f.with_suffix(".meta.json").read_text()).get("agentType") or "subagent" + except (OSError, ValueError): + lane = "subagent" + files.append((f, lane)) + for path, lane in files: + last = {} + for line in open(path, errors="replace"): + try: + d = json.loads(line) + except ValueError: + continue + kind = d.get("type") + if kind == "assistant" and isinstance(d.get("message"), dict): + last[d["message"].get("id") or id(d)] = d["message"] + elif lane == "main" and kind in ("custom-title", "ai-title"): + names[kind] = d.get("customTitle") or d.get("aiTitle") or names.get(kind) + elif lane == "main" and kind == "cost-state": + state = d + elif lane == "main" and kind == "pr-link": + pr = d + for key, m in last.items(): + if key in seen or not isinstance(m.get("usage"), dict) or m.get("model") == "": + continue + seen.add(key) + t = tokens(m["usage"]) + add(rows[(lane, m.get("model"))], t, priced(prices, m.get("model"), t)) + for model, v in ((state or {}).get("modelUsage") or {}).items(): + mine = combine(r for (_, mo), r in list(rows.items()) if mo == model) + extra = {k: max(0, (v.get(f) or 0) - mine[k][0]) for k, f in zip(KINDS, FIELDS)} + if any(extra.values()): + t = {"input": extra["input"], "output": extra["output"], "w5": extra["write"], "w1": 0, "read": extra["read"]} + d = priced(prices, model, t, per_request=False) + if d is not None: + # The record does not split cache writes by duration: they take what is left of Claude Code's + # figure, between the 5-minute and the 1-hour price. + hi = priced(prices, model, dict(t, w5=0, w1=extra["write"]), per_request=False)["write"] + rest = (v.get("costUSD") or 0) - (total(mine)[1] or 0) - d["input"] - d["output"] - d["read"] + d["write"] = min(max(rest, d["write"]), hi) + add(rows[("background", model)], t, d) + title = names.get("custom-title") or names.get("ai-title") or "Session " + base.stem[:8] + return rows, title, state, pr + + +def combine(rows): + whole = {k: [0, 0.0] for k in KINDS} + for r in rows: + for k in KINDS: + whole[k][0] += r[k][0] + whole[k][1] = None if r[k][1] is None or whole[k][1] is None else whole[k][1] + r[k][1] + return whole + + +def total(row): + dollars = [row[k][1] for k in KINDS] + return sum(row[k][0] for k in KINDS), None if None in dollars else sum(dollars) + + +def disagree(prices, state): + """Models whose Claude Code figure is outside what ck's prices allow for Claude Code's own token counts.""" + out = [] + for model, v in ((state or {}).get("modelUsage") or {}).items(): + t = dict(zip(("input", "output", "w5", "read"), (v.get(f) or 0 for f in FIELDS)), w1=0) + lo, hi = priced(prices, model, t, False), priced(prices, model, dict(t, w5=0, w1=t["w5"])) + if lo and hi and not v.get("webSearchRequests") and not ( + 0.98 * sum(lo.values()) <= (v.get("costUSD") or 0) <= 1.02 * sum(hi.values())): + out.append(short(model)) + return out + + +def num(n): + return f"{n / 1e6:.1f}M" if n >= 1e6 else f"{n / 1e3:.1f}k" if 1e3 <= n < 1e4 else f"{n / 1e3:.0f}k" if n >= 1e4 else str(n) + + +def cell(n, dollars): + return f"{num(n)} · " + ("?" if dollars is None else f"${dollars:.2f}") + + +def short(model): + return (model or "?").removeprefix("claude-") + + +def table(session, prices, source): + rows, title, state, _ = session + out = [f"**{title} cost summary**", "", "| Lane | Model " + HEAD, "|---|---|--:|--:|--:|--:|--:|"] + for lane, model in sorted(rows, key=lambda r: (r[0] != "main", r[0] == "background", r[0], r[1] or "")): + r = rows[(lane, model)] + out.append(f"| {lane} | {short(model)} | " + " | ".join(cell(*r[k]) for k in KINDS) + f" | **{cell(*total(r))}** |") + whole = combine(rows.values()) + out.append("| **All** | | " + " | ".join(cell(*whole[k]) for k in KINDS) + f" | **{cell(*total(whole))}** |") + out += ["", source] + if any(lane == "background" for lane, _ in rows): + out.append("Background is what Claude Code billed beyond the transcripts: its own calls.") + mine, theirs = total(whole)[1], (state or {}).get("totalCostUSD") + if theirs is None: + out.append("Claude Code wrote no cost record for this session, so background calls are not counted.") + else: + out.append(f"Claude Code's own figure: ${theirs:.2f}.") + if mine is not None and mine > 1.03 * theirs: + out.append("Claude Code's record was written before the session's last turns, so its figure is lower.") + if disagree(prices, state): + out.append(f"The pricing page and Claude Code's own figure disagree for {', '.join(disagree(prices, state))}: one of them has an old price.") + return "\n".join(out) + + +def newest(cwd): + """The most recent transcript of the repository in cwd, its worktrees included.""" + git = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=cwd, + capture_output=True, text=True, timeout=10).stdout.strip() + mangled = re.sub(r"[^A-Za-z0-9]", "-", str(Path(git).parent) if git else cwd) + files = list(PROJECTS.glob(f"{mangled}/*.jsonl")) + list(PROJECTS.glob(f"{mangled}--claude-worktrees-*/*.jsonl")) + return max(files, key=os.path.getmtime, default=None) + + +def stop(hook, prices, source): + rows, _, _, pr = read(hook["transcript_path"], prices) + if not pr: + return + models = {m: total(combine(r for (_, mo), r in rows.items() if mo == m)) for _, m in rows} + parts = ", ".join(f"{short(m)} {cell(*v)}" for m, v in sorted(models.items(), key=lambda i: -(i[1][1] or 0))) + print(json.dumps({"systemMessage": f"ck cost so far: {cell(*total(combine(rows.values())))} ({parts})"})) + + +def merge(hook, prices, source): + command = (hook.get("tool_input") or {}).get("command") or "" + number = re.search(r"\bgh\s+pr\s+merge\s+(?:-\S+\s+)*#?(\d+)", command) + + def gh(*args): + return subprocess.run(["gh", *args], cwd=hook.get("cwd"), capture_output=True, text=True, timeout=20).stdout + + info = json.loads(gh("pr", "view", *([number.group(1)] if number else []), "--json", "number,state,mergeCommit,url")) + if info.get("state") != "MERGED" or (LOG.exists() and f'"{info["url"]}"' in LOG.read_text()): + return + session = read(hook["transcript_path"], prices) + gh("pr", "comment", str(info["number"]), "--body", table(session, prices, source)) + n, d = total(combine(session[0].values())) + LOG.parent.mkdir(parents=True, exist_ok=True) + with open(LOG, "a") as f: + f.write(json.dumps({"date": time.strftime("%Y-%m-%d"), "url": info["url"], "commit": (info.get("mergeCommit") or {}).get("oid"), + "session": Path(hook["transcript_path"]).stem, "title": session[1], "tokens": n, "dollars": d}) + "\n") + + +def history(): + out = ["**Merged pull requests**", "", "| Date | Pull request | Commit | Session | Tokens | $ |", "|---|---|---|---|--:|--:|"] + for line in LOG.read_text().splitlines() if LOG.exists() else []: + try: + e = json.loads(line) + pr = re.sub(r"https://github.com/(.+)/pull/(\d+)", r"\1#\2", e["url"]) + out.append(f"| {e['date']} | [{pr}]({e['url']}) | {(e.get('commit') or '')[:7]} | {e['title']} | {num(e['tokens'])} | " + + ("?" if e.get("dollars") is None else f"${e['dollars']:.2f}") + " |") + except (ValueError, KeyError, TypeError): + continue + return "\n".join(out) if len(out) > 4 else "No merged pull requests are logged yet in " + str(LOG) + "." + + +def main(): + args = sys.argv[1:] + if args[:1] in (["stop"], ["merge"]): + hook = json.load(sys.stdin) + if args[0] == "stop": + stop(hook, *pricing()) + elif re.search(r"\bgh\s+pr\s+merge\b", (hook.get("tool_input") or {}).get("command") or ""): + merge(hook, *pricing()) + return 0 + if args in (["history"], ["--history"]): + print(history()) + return 0 + if args[:1] == ["--session"]: + args = args[1:] + if len(args) > 1 or args[:1] and args[0].startswith("-"): + print("usage: cost.py [SESSION ID | TRANSCRIPT] | history | stop | merge", file=sys.stderr) + return 2 + if not args: + path = newest(os.getcwd()) + elif Path(args[0]).is_file(): + path = args[0] + else: + path = max(PROJECTS.glob(f"*/{args[0]}*.jsonl"), key=os.path.getmtime, default=None) + if not path: + print("No session transcript found under ~/.claude/projects" + (f" for {args[0]}." if args else " for this project.")) + return 1 + prices, source = pricing() + print(table(read(path, prices), prices, source)) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + if sys.argv[1:2] in (["stop"], ["merge"]): + sys.exit(0) + print(f"cost.py: {e}", file=sys.stderr) + sys.exit(1) diff --git a/skills/cost/SKILL.md b/skills/cost/SKILL.md new file mode 100644 index 0000000..9b969f2 --- /dev/null +++ b/skills/cost/SKILL.md @@ -0,0 +1,14 @@ +--- +name: cost +description: What this session has cost so far, in tokens and dollars, by lane (the main session, each persona, Claude Code's own calls) and by kind (input, output, cache write, cache read), priced from Anthropic's pricing page. With "history", one row per merged pull request. Runs only when typed. +disable-model-invocation: true +argument-hint: "[history | ]" +--- + +Run this exact command from the folder Claude Code was opened in, and nothing else: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/cost.py" $ARGUMENTS +``` + +Show what it printed, exactly as printed, as Markdown, with nothing before or after it. If it printed an error, show that line and stop. diff --git a/tests/fixtures/cost/pricing.md b/tests/fixtures/cost/pricing.md new file mode 100644 index 0000000..4a4e6d8 --- /dev/null +++ b/tests/fixtures/cost/pricing.md @@ -0,0 +1,9 @@ +# Pricing (made up for the tests, in the page's layout) + +| Model | Base input tokens | 5m cache writes | 1h cache writes | Cache hits and refreshes | Output tokens | +| :--- | :--- | :--- | :--- | :--- | :--- | +| Claude Opus 5.5 | $4 / MTok | $5 / MTok | $8 / MTok | $0.20 / MTok2 | $20 / MTok | +| Claude Haiku 5.5 (for prompts up to 100,000 tokens) | $0.10 / MTok | $0.125 / MTok | $0.20 / MTok | $0.01 / MTok | $0.50 / MTok | +| Claude Haiku 5.5 (for prompts over 100,000 tokens) | $0.50 / MTok | $0.625 / MTok | $1 / MTok | $0.05 / MTok | $2.50 / MTok | + +Other text. diff --git a/tests/fixtures/cost/s1.jsonl b/tests/fixtures/cost/s1.jsonl new file mode 100644 index 0000000..3de3df3 --- /dev/null +++ b/tests/fixtures/cost/s1.jsonl @@ -0,0 +1,5 @@ +{"type": "user", "message": {"role": "user", "content": "a made-up prompt"}} +{"type": "assistant", "message": {"id": "m1", "model": "claude-opus-5-5", "usage": {"input_tokens": 1000000, "output_tokens": 100000, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 1000000, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 1000000}, "speed": "standard"}, "content": [{"type": "text", "text": "made up"}]}} +{"type": "assistant", "message": {"id": "m1", "model": "claude-opus-5-5", "usage": {"input_tokens": 1000000, "output_tokens": 100000, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 1000000, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 1000000}, "speed": "standard"}, "content": [{"type": "text", "text": "made up"}]}} +{"type": "custom-title", "customTitle": "Fixture session"} +{"type": "cost-state", "totalCostUSD": 15.1025, "modelUsage": {"claude-opus-5-5": {"inputTokens": 1000000, "outputTokens": 150000, "cacheCreationInputTokens": 1000000, "cacheReadInputTokens": 0, "costUSD": 15.0}, "claude-haiku-5-5": {"inputTokens": 200000, "outputTokens": 1000, "cacheCreationInputTokens": 0, "cacheReadInputTokens": 0, "costUSD": 0.1025}}} diff --git a/tests/fixtures/cost/s1/subagents/agent-r.jsonl b/tests/fixtures/cost/s1/subagents/agent-r.jsonl new file mode 100644 index 0000000..11198ed --- /dev/null +++ b/tests/fixtures/cost/s1/subagents/agent-r.jsonl @@ -0,0 +1 @@ +{"type": "assistant", "message": {"id": "m2", "model": "claude-haiku-5-5", "usage": {"input_tokens": 200000, "output_tokens": 1000, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "speed": "standard"}, "content": [{"type": "text", "text": "made up"}]}} diff --git a/tests/fixtures/cost/s1/subagents/agent-r.meta.json b/tests/fixtures/cost/s1/subagents/agent-r.meta.json new file mode 100644 index 0000000..03e670f --- /dev/null +++ b/tests/fixtures/cost/s1/subagents/agent-r.meta.json @@ -0,0 +1 @@ +{"agentType": "ck:robin"} diff --git a/tests/fixtures/cost/s2.jsonl b/tests/fixtures/cost/s2.jsonl new file mode 100644 index 0000000..b8127eb --- /dev/null +++ b/tests/fixtures/cost/s2.jsonl @@ -0,0 +1,2 @@ +{"type": "custom-title", "customTitle": "Mystery session"} +{"type": "assistant", "message": {"id": "m3", "model": "claude-mystery-1", "usage": {"input_tokens": 10, "output_tokens": 10, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "speed": "standard"}, "content": [{"type": "text", "text": "made up"}]}} diff --git a/tests/run.sh b/tests/run.sh index 5fe9176..f83658e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -822,6 +822,43 @@ out="$(gate "$(call Write file_path /tmp/x.md)")$(gate "$(call Bash command 'cat python3 -c 'import json; h=json.load(open("hooks/hooks.json"))["hooks"]; assert any("memory-gate.py\" check" in x["command"] for e in h["PreToolUse"] for x in e["hooks"]); assert any("memory-gate.py\" rule" in x["command"] for e in h["SessionStart"] for x in e["hooks"])' \ && ok "hooks.json runs the gate before tool calls and prints its rule at session start" || fail "hooks.json does not wire the memory gate" +# ─── 22. The cost script ───────────────────────────────────────────────────── +section "22. /ck:cost: scripts/cost.py and its hooks" +CF=tests/fixtures/cost +cost() { CK_PRICING_FILE=$CF/pricing.md HOME="$TMP/costhome" PATH="$TMP/costbin:$PATH" python3 scripts/cost.py "$@"; } +out=$(cost $CF/s1.jsonl) +if grep -qF '| main | opus-5-5 | 1.0M · $4.00 | 100k · $2.00 | 1.0M · $8.00 | 0 · $0.00 | **2.1M · $14.00** |' <<<"$out" \ + && grep -qF '| ck:robin | haiku-5-5 | 200k · $0.10 |' <<<"$out" \ + && grep -qF '| background | opus-5-5 | 0 · $0.00 | 50k · $1.00 |' <<<"$out" \ + && grep -qF '| **All** | | 1.2M · $4.10 | 151k · $3.00 | 1.0M · $8.00 | 0 · $0.00 | **2.4M · $15.10** |' <<<"$out" \ + && grep -qF "Claude Code's own figure: \$15.10." <<<"$out" && ! grep -q 'disagree\|made-up' <<<"$out"; then + ok "lanes add up to the All row and to Claude Code's figure; a message counts once; a long Haiku prompt takes the higher price; no message text" +else fail "the cost table for the fixture session is wrong: $out"; fi +out=$(cost $CF/s2.jsonl) +grep -qF '| main | mystery-1 | 10 · ? |' <<<"$out" && grep -qF '**20 · ?**' <<<"$out" && ! grep -qF '$0.00' <<<"$out" \ + && ok "a model the pricing page does not list is priced as ?, never \$0" || fail "an unknown model is not marked ?: $out" +mkdir -p "$TMP/costbin" "$TMP/costhome" +printf '%s\n' '#!/bin/sh' 'case "$1 $2" in' \ + " \"pr view\") echo '{\"number\": 7, \"state\": \"MERGED\", \"mergeCommit\": {\"oid\": \"abc1234def\"}, \"url\": \"https://github.com/o/r/pull/7\"}' ;;" \ + " \"pr comment\") printf '%s\\n' \"\$*\" >> \"$TMP/comments\" ;;" 'esac' > "$TMP/costbin/gh" +chmod +x "$TMP/costbin/gh" +cp $CF/s1.jsonl "$TMP/s3.jsonl"; cp -R $CF/s1 "$TMP/s3" +echo '{"type": "pr-link", "prNumber": 7}' >> "$TMP/s3.jsonl" +hook() { printf '{"transcript_path": "%s", "cwd": "%s", "tool_input": {"command": "%s"}}' "$1" "$TMP" "$2"; } +quiet=$(hook $CF/s1.jsonl "" | cost stop) +line=$(hook "$TMP/s3.jsonl" "" | cost stop | python3 -c 'import json,sys; print(json.load(sys.stdin)["systemMessage"])' 2>&1) +[[ -z "$quiet" && "$line" == "ck cost so far: 2.4M · \$15.10 ("* ]] \ + && ok "the Stop hook is quiet until the session has a pull request, then prints the cost so far" || fail "the Stop hook printed '$quiet' then '$line'" +hook "$TMP/s3.jsonl" "git status" | cost merge +hook "$TMP/s3.jsonl" "gh pr merge 7 --merge" | cost merge +hook "$TMP/s3.jsonl" "gh pr merge 7 --merge" | cost merge +hist=$(cost history) +[[ $(grep -c 'Fixture session cost summary' "$TMP/comments" 2>/dev/null) == 1 ]] && grep -qF '| [o/r#7](https://github.com/o/r/pull/7) | abc1234 | Fixture session | 2.4M | $15.10 |' <<<"$hist" \ + && ok "after gh pr merge the hook posts the table to the pull request once and logs it, and history lists it" || fail "the merge hook: $(grep -c 'cost summary' "$TMP/comments" 2>/dev/null) comments, history: $hist" +python3 -c 'import json; h=json.load(open("hooks/hooks.json"))["hooks"]; assert any("cost.py\" stop" in x["command"] for e in h["Stop"] for x in e["hooks"]); assert any("cost.py\" merge" in x["command"] for e in h["PostToolUse"] for x in e["hooks"])' \ + && grep -qx 'disable-model-invocation: true' skills/cost/SKILL.md \ + && ok "hooks.json runs the cost hooks, and /ck:cost runs only when typed" || fail "the cost hooks or /ck:cost are not wired" + # ─── Summary ───────────────────────────────────────────────────────────────── printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" if (( FAIL > 0 )); then