diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3e799087..665c1f54 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -173,7 +173,7 @@ "url": "https://github.com/amd/skills.git", "path": "skills", "ref": "main", - "sha": "7ef8dd99ffd6d82208f1a0ae211a782ec2edee82" + "sha": "228980933abd97a8aa4a2656c8ea3ae9e8b590c7" }, "strict": false, "skills": [ @@ -207,7 +207,7 @@ "source": { "source": "url", "url": "https://github.com/apolloio/apollo-mcp-plugin.git", - "sha": "1fa5c26b6c99283883db5858804234fbe1c10818" + "sha": "2adde980e45f421b7e9383d92870455627936bce" }, "homepage": "https://www.apollo.io/" }, @@ -253,7 +253,7 @@ "source": { "source": "url", "url": "https://github.com/astronomer/agents.git", - "sha": "5432198caa8ef9ec33218124bcd44016db2d31bf" + "sha": "87d780cd5dc85a25c7ceb1f1720031fa4e48f781" }, "homepage": "https://github.com/astronomer/agents" }, @@ -643,7 +643,7 @@ "url": "https://github.com/carta/plugins.git", "path": "plugins/carta-investors", "ref": "main", - "sha": "a007723c7149acab9f9357eaa74b98635bb63934" + "sha": "f2c2cbb11ed4dfabf88c59d61827b4b1aefb0611" }, "homepage": "https://carta.com" }, @@ -899,7 +899,7 @@ "source": { "source": "url", "url": "https://github.com/coderabbitai/skills.git", - "sha": "2fd091d9582deccf19929e21f934921d0e8f686e" + "sha": "c61c4908d36dda6fb08a343cc8b19c59fbb47de3" }, "homepage": "https://github.com/coderabbitai/skills" }, @@ -1052,7 +1052,7 @@ "source": { "source": "url", "url": "https://github.com/astronomer/agents.git", - "sha": "5432198caa8ef9ec33218124bcd44016db2d31bf" + "sha": "87d780cd5dc85a25c7ceb1f1720031fa4e48f781" }, "homepage": "https://github.com/astronomer/agents" }, @@ -1076,7 +1076,7 @@ "source": { "source": "url", "url": "https://github.com/astronomer/agents.git", - "sha": "5432198caa8ef9ec33218124bcd44016db2d31bf" + "sha": "87d780cd5dc85a25c7ceb1f1720031fa4e48f781" }, "homepage": "https://github.com/astronomer/agents" }, @@ -1189,7 +1189,7 @@ "source": { "source": "url", "url": "https://github.com/confident-ai/deepeval.git", - "sha": "ae2cb499653628ef7b1f44337715456b680aee5f" + "sha": "6bb1881c8caaae3a0348ad9040d506df7332be80" }, "homepage": "https://github.com/confident-ai/deepeval" }, @@ -1506,7 +1506,7 @@ "url": "https://github.com/grafana/ai-marketplace.git", "path": "plugins/grafana-cloud-mcp", "ref": "main", - "sha": "a5c72f2d74c640e9675eb0249526447968535015" + "sha": "12be5634a492f73c189d466c5449d09b853ad7a4" }, "homepage": "https://grafana.com" }, @@ -1610,7 +1610,7 @@ "source": { "source": "url", "url": "https://github.com/heygen-com/hyperframes.git", - "sha": "ff3627d5d97c32b88d9faf79ad8d6126ab673074" + "sha": "911b332bb2131f1b3fc4abbea563bf0c70d165c4" }, "homepage": "https://hyperframes.heygen.com" }, @@ -2620,7 +2620,7 @@ }, { "name": "receipts", - "description": "Generate a personal Claude Code impact report from your local ~/.claude/projects transcripts, cross-referenced against your local git history — what you shipped, which projects it went to, and each project's share of your usage — for justifying usage to a manager or a self-review. Reads your session transcripts and runs read-only git commands in the projects they mention; mining is local and only a small aggregate summary (counts and project names) is sent to write the report, which is saved to your home directory and published nowhere.", + "description": "A personal Claude Code impact report for justifying your usage to a manager or a self-review: what you shipped, which projects it went to, and each project's share of your usage. Reads your ~/.claude/projects transcripts and runs read-only git locally; only counts and project names are sent to write it up, and the report stays on your machine.", "author": { "name": "Anthropic", "email": "support@anthropic.com" @@ -2910,7 +2910,7 @@ "source": "git-subdir", "url": "https://github.com/semgrep/mcp-marketplace.git", "path": "plugin", - "sha": "63fac121296f3f102651de6beb7c15649c8b11d3" + "sha": "7ae38e4bec51877cec71fcacc5857d746fb8b468" }, "homepage": "https://github.com/semgrep/mcp-marketplace.git" }, @@ -3087,7 +3087,7 @@ "source": { "source": "url", "url": "https://github.com/spotify/ads-claude-plugin.git", - "sha": "aedf7ed87312891ef2e357c02b9c22fcca8907b4" + "sha": "6f0b741c8dabb92492a74e6e6893f81e1b94b038" }, "homepage": "https://github.com/spotify/ads-claude-plugin" }, @@ -3554,7 +3554,7 @@ "source": { "source": "url", "url": "https://github.com/langfuse/skills.git", - "sha": "b6253b2c659c93a4033bb9a6e7c04d7e2ebc0de6" + "sha": "abe69fac6b0e0df94605dc5ae162da4726d0fb78" }, "homepage": "https://langfuse.com" }, diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index 51920c8f..490bec72 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -19,6 +19,15 @@ on: # pull_request and the required check would sit "Expected" forever (a dispatch # check run isn't associated with the PR, so it can't satisfy the gate either). - '.github/policy/**' + # And once more for a plugin's own docs: a PR that only edits a README or + # adds a screenshot matches nothing above, so the required check never + # reports and the PR can't be merged. Spelled out per level because `*` + # doesn't cross a `/` — plugins live at plugins//, so `*/README.md` + # would not match one. + - 'plugins/*/README.md' + - 'plugins/*/assets/**' + - 'external_plugins/*/README.md' + - 'external_plugins/*/assets/**' push: branches: [main] paths: diff --git a/plugins/receipts/README.md b/plugins/receipts/README.md index 52330912..9e2db44c 100644 --- a/plugins/receipts/README.md +++ b/plugins/receipts/README.md @@ -4,6 +4,20 @@ Generate a personal Claude Code impact report — "receipts" — from your own session transcripts, for the conversation where someone asks what all this Claude Code usage is actually buying. +A printed-receipt-styled report headed 'Claude Code — usage receipt, Morgan Lunt', covering 2026-06-21 to 2026-07-20, active on 28 of 30 days. It lists 40 sessions, 64 prompts, 14 files touched, 3,120 lines touched, 12 commits carrying that work and 7 PRs opened, headlined as 19 commits and PRs shipped. A by-project table gives 'Research and investigation (no project)' 53% of spend with no files touched, then acme-api 29%, acme-web 10%, billing-service 5%, infra-terraform 3%, and a plain ~/notes directory 1%. Footnotes explain which columns do not sum, and an Export CSV button sits above a barcode. + +*Sample output — the projects and numbers are invented. It's rendered from a +synthetic corpus, not from anyone's real usage.* + +## Install + +``` +/plugin install receipts@claude-plugins-official +``` + +## Use + ``` /receipts # last 30 days (default) /receipts week # last 7 days @@ -12,115 +26,52 @@ Claude Code usage is actually buying. /receipts for myrepo # scope to one project ``` -You get two files in your home directory: a markdown report to paste into a -doc or a review, and a self-contained HTML receipt to open or attach. The -receipt has an **Export CSV** button for the by-project table, and prints to a -clean PDF. +Two files land in your home directory: a markdown report to paste into a doc or +a review, and a self-contained HTML receipt to open or attach. The receipt has +an **Export CSV** button and prints to a clean PDF. Nothing is published +anywhere. -## What it reports +Takes a few seconds — about 1s for a week, 5s for a year. + +## What you get - **What you shipped** — files and lines touched, commits carrying that work, PRs opened. - **By project** — sessions, active days, and each project's share of your - total compute. + usage. Work outside a repo is named for its directory; sessions that touched + no files at all (web searches, chat tools, dashboards) show as + *Research & investigation (no project)*, which for a lot of people is the + biggest row. - **Framing for a manager** — how to present the above without overclaiming. -## What counts - -The report's universe is **work you did with Claude Code**, mapped to the -project you did it on. Two consequences worth knowing before you read a number: - -**Claude Code's own machinery isn't your work.** The agent's scratchpad, its -per-session tool output, and `~/.claude` are excluded. On a real 30-day corpus -that removed 82% of the raw "lines touched" figure — files Claude wrote to talk -to itself, which no one shipped. - -**A project is where work landed, not where your shell was.** Sessions are -attributed to the projects their file operations touched (reads included — -reading a repo to answer a question is work in that repo), resolved to the git -root, or to the containing directory when it isn't a repo. Work outside a repo -still counts; it's named for its directory. Subagents share their parent's -session, so their work lands on the same project — there's no "delegated" -category, because delegation is a mechanism, not a kind of work. - -Sessions that touched no files and didn't run in a repo — web searches, Slack -reads, dashboard queries — land in **Research & investigation (no project)**. -That row is often the biggest one. It's real work that genuinely has no home on -disk, and naming it beats inventing a project for it. - -## Design notes - -**No dollar figures, anywhere.** A cost computed from local token counts is -inferred, not measured, and won't match your actual bill. Presenting one -invites the "that can't be right" reaction that discredits everything else in -the report. Spend appears only as relative percentages. - -**No invented "hours saved."** There's no baseline in local data to compute a -counterfactual from, and a fabricated multiplier undermines the real numbers -sitting next to it. The report deliberately leaves room for you to add -concrete wins by hand — those land better than any aggregate anyway. - -**No breakdown of spend by activity.** "38% of your compute went to reading -code" is the chart everyone wants and the data can't support. A turn's cost is -~90% context handling, half of it re-reading what earlier turns added, so -charging it to whichever tool fired that turn is a modeling choice rather than -a measurement — and the choice decides the answer. On one real month, three -equally defensible weightings put web search at 11%, 28% or 51%. Spend appears -once, per project, where it divides a real quantity by a real fact and the -ranking holds whichever weighting you pick. - -**Careful claims.** Commits are counted only when they were authored under the -identity git uses in that repo *and* their changed files include something -Claude Code touched. Both tests have to pass, which is what keeps a snapshot -cron out — it commits under your name but never touches the files Claude -edited — while still counting the commit you made by hand after Claude wrote -the code. The gap it leaves: a repo configured with a *shared* identity (a -release bot's, say) makes that bot "you" for that repo, so a bot commit -touching a file Claude also edited would count. Rare, and the alternative — -reading only your global identity — silently zeroes the commit count for -anyone using git's standard `includeIf` work/personal split, which is far more -common. - -Lines are "touched", not "written". Sessions, active days and commits are all -marked as columns that don't sum: a session spanning two projects is genuinely -in both, and worktrees of one repo share commits. +A few things the report deliberately won't tell you: no dollar figures, no +"hours saved", and no breakdown of spend by activity. Each of those would be a +guess dressed up as a measurement, and one bad number discredits the rest of +the page. What's left is meant to survive someone pushing back on it. ## Privacy -Mining is a local Node script — file I/O and `git`, no network calls. +Everything is read locally — file I/O and read-only `git`, no network calls. -It reads `~/.claude/projects/**/*.jsonl` — your own session history, already on -disk, all projects, for the window you ask for. To find out which of the -directories in there are repos, it runs `git rev-parse` in **every** directory -any session mentioned (on one real month, 152 of them), and in the ones that -are repos it also reads `user.email` and runs `git log`. All read-only, all -local. +It reads `~/.claude/projects/**/*.jsonl`, your own session history, already on +disk. To work out which of the directories mentioned there are git repos, it +runs `git rev-parse` in each of them, and in the ones that are, reads +`user.email` and runs `git log`. -The only thing that reaches the model is a small JSON summary: your name from -`git config user.name`, aggregate counts, and project names. Your email is read -but never emitted — it's used locally to match commit authorship. No code, no -conversation content, and no tool or MCP server names — the report has no -per-tool breakdown at all, so the list of services you've connected never -leaves the script. +The only thing that reaches the model is a small summary: your name from +`git config`, aggregate counts, and project names. No code, no conversation +content, and no tool or MCP server names — so the list of services you've +connected never leaves your machine. Your email is used locally to match commit +authorship and is never sent. -The report is written to your disk and published nowhere unless you explicitly -ask for a shareable version. Because repo names appear verbatim, the skill -lists them before you send the report anywhere. +Project names appear verbatim in the report, so the skill reads them back to +you before you send it anywhere. Use `/receipts for ` to scope it down. -## Relationship to `session-report` +## Which plugin do I want? -Both plugins read the same transcripts, and that's about where the similarity -ends. +[`session-report`](../session-report) reads the same transcripts to answer +*where am I wasting tokens* — cache hit rates, expensive prompts — and hands +you a list of optimizations. Install it to make your usage cheaper. -[`session-report`](../session-report) is a tuning tool. It asks *where am I -wasting tokens* — cache hit rates, disproportionate projects, expensive -prompts — and its output is a list of optimizations. The audience is you, and -the goal is to drive usage down. - -`receipts` is a justification tool. It asks *was this worth it* — what shipped, -in which repos, against what spend — and cross-references local git history to -tie usage to output. The audience is your manager, and the goal is to defend -the spend rather than trim it. - -Install `session-report` to make your usage cheaper. Install `receipts` to -explain why it was worth paying for. +`receipts` answers *was this worth it* — what shipped, in which projects, +against what spend. Install it to explain why the usage was worth paying for. diff --git a/plugins/receipts/assets/make-sample.mjs b/plugins/receipts/assets/make-sample.mjs new file mode 100644 index 00000000..b9ee1fd5 --- /dev/null +++ b/plugins/receipts/assets/make-sample.mjs @@ -0,0 +1,183 @@ +// make-sample.mjs — regenerate assets/sample-receipt.png for the README. +// +// The README's sample must never contain anyone's real projects, so it isn't a +// screenshot of a real run. This builds a throwaway HOME with invented repos, +// invented files and invented sessions, then points the real miner at it — the +// picture is genuinely what the tool produces, from data that never existed. +// +// node assets/make-sample.mjs ~/receipts-sample +// HOME=~/receipts-sample/home node skills/receipts/scripts/mine-transcripts.mjs \ +// --days 30 --html /tmp/sample.html +// # screenshot /tmp/sample.html at 620px wide, crop to the receipt +// +// Build it somewhere with no symlink above it — NOT /tmp, which on macOS is a +// symlink to /private/tmp. `git rev-parse --show-toplevel` resolves symlinks +// and these transcripts don't, so under /tmp every repo reports zero commits +// and the sample comes out silently wrong. +// +// Keep the invented names obviously fake (acme-*, example.com). + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const ROOT = process.argv[2]; +const HOME = path.join(ROOT, 'home'); +fs.rmSync(ROOT, { recursive: true, force: true }); + +const NAME = 'Morgan Lunt'; +const EMAIL = 'morgan@example.com'; + +const git = (cwd, ...a) => execFileSync('git', ['-C', cwd, ...a], { stdio: ['ignore', 'pipe', 'ignore'] }); + +// --- invented projects ------------------------------------------------------- +const REPOS = { + 'acme-api': ['src/routes/orders.ts', 'src/routes/refunds.ts', 'src/db/schema.sql', 'src/lib/auth.ts', 'tests/orders.test.ts'], + 'acme-web': ['app/checkout/page.tsx', 'app/cart/state.ts', 'components/PriceTag.tsx'], + 'billing-service': ['internal/invoice/render.go', 'internal/tax/rates.go'], + 'infra-terraform': ['envs/prod/main.tf', 'modules/rds/variables.tf'], +}; +const NOTES = ['migration-plan.md', 'oncall-runbook.md']; + +fs.mkdirSync(path.join(HOME, 'notes'), { recursive: true }); +fs.writeFileSync(path.join(HOME, '.gitconfig'), `[user]\n\tname = ${NAME}\n\temail = ${EMAIL}\n`); +for (const f of NOTES) fs.writeFileSync(path.join(HOME, 'notes', f), 'note\n'.repeat(40)); + +for (const [repo, files] of Object.entries(REPOS)) { + const dir = path.join(HOME, 'code', repo); + fs.mkdirSync(dir, { recursive: true }); + git(dir, 'init', '-q', '-b', 'main'); + git(dir, 'config', 'user.name', NAME); + git(dir, 'config', 'user.email', EMAIL); + git(dir, 'config', 'commit.gpgsign', 'false'); + for (const f of files) { + fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); + fs.writeFileSync(path.join(dir, f), 'x\n'.repeat(60)); + } + git(dir, 'add', '-A'); + git(dir, 'commit', '-qm', 'initial'); +} + +// --- invented transcripts ---------------------------------------------------- +const PROJ = path.join(HOME, '.claude', 'projects', 'sample'); +fs.mkdirSync(PROJ, { recursive: true }); + +let uid = 0; +const U = () => `u${++uid}`; +const day = (back) => { + const d = new Date(); + d.setDate(d.getDate() - back); + d.setHours(10 + (back % 6), 15, 0, 0); + return d.toISOString(); +}; +const usage = (out) => ({ + input_tokens: 900, + output_tokens: out, + cache_creation: { ephemeral_5m_input_tokens: 4000, ephemeral_1h_input_tokens: 0 }, + cache_read_input_tokens: 30000, +}); +const asst = (sid, ts, cwd, blocks, out = 300) => ({ + type: 'assistant', sessionId: sid, uuid: U(), requestId: `r${uid}`, timestamp: ts, cwd, + message: { usage: usage(out), content: blocks }, +}); +const user = (sid, ts, cwd, text) => ({ + type: 'user', sessionId: sid, uuid: U(), promptId: `p${uid}`, timestamp: ts, cwd, + message: { content: text }, +}); +const tool = (name, input) => ({ type: 'tool_use', id: `t${++uid}`, name, input }); + +// One .jsonl per session, the way Claude Code actually lays them out. +const bySession = new Map(); +const emit = (o) => { + const k = o.sessionId; + if (!bySession.has(k)) bySession.set(k, []); + bySession.get(k).push(JSON.stringify(o)); +}; + +// Sessions that build things, spread across the invented repos. +const plan = [ + { repo: 'acme-api', sessions: 9, daysBack: [2, 3, 5, 6, 9, 12, 16, 20, 24], edits: 5, writes: 2 }, + { repo: 'acme-web', sessions: 5, daysBack: [4, 7, 11, 18, 22], edits: 3, writes: 1 }, + { repo: 'billing-service', sessions: 3, daysBack: [8, 15, 26], edits: 2, writes: 1 }, + { repo: 'infra-terraform', sessions: 2, daysBack: [13, 19], edits: 2, writes: 0 }, +]; +let sid = 0; +for (const p of plan) { + const dir = path.join(HOME, 'code', p.repo); + const files = REPOS[p.repo]; + for (let i = 0; i < p.sessions; i++) { + const S = `s-${p.repo}-${++sid}`; + const ts = day(p.daysBack[i % p.daysBack.length]); + emit(user(S, ts, dir, 'add the thing')); + for (let e = 0; e < p.edits; e++) { + const f = path.join(dir, files[e % files.length]); + emit(asst(S, ts, dir, [tool('Read', { file_path: f })], 120)); + emit(asst(S, ts, dir, [tool('Edit', { file_path: f, old_string: 'x\n'.repeat(9), new_string: 'y\n'.repeat(14) })], 400)); + } + for (let w = 0; w < p.writes; w++) { + const f = path.join(dir, files[(w + 1) % files.length]); + emit(asst(S, ts, dir, [tool('Write', { file_path: f, content: 'z\n'.repeat(70) })], 700)); + } + emit(asst(S, ts, dir, [tool('Bash', { command: 'npm test' })], 200)); + if (i % 3 === 0) { + emit(user(S, ts, dir, 'open the PR')); + emit(asst(S, ts, dir, [tool('Bash', { command: 'gh pr create --fill' })], 150)); + } + } +} + +// Work in a plain directory — no repo. +for (let i = 0; i < 4; i++) { + const S = `s-notes-${i}`; + const ts = day([6, 10, 17, 23][i]); + const dir = path.join(HOME, 'notes'); + emit(user(S, ts, dir, 'draft the migration plan')); + emit(asst(S, ts, dir, [tool('Write', { file_path: path.join(dir, NOTES[i % 2]), content: 'n\n'.repeat(55) })], 900)); +} + +// Research: no files touched, not in a repo. The row that surprises people. +for (let i = 0; i < 17; i++) { + const S = `s-res-${i}`; + const ts = day([1, 2, 3, 5, 7, 8, 9, 11, 12, 14, 16, 18, 20, 21, 25, 27, 28][i]); + emit(user(S, ts, HOME, 'what changed in the pricing API?')); + for (let k = 0; k < 6; k++) { + emit(asst(S, ts, HOME, [tool('WebFetch', { url: 'https://example.com/docs' })], 350)); + emit(asst(S, ts, HOME, [tool('WebSearch', { query: 'pricing api changelog' })], 250)); + } + emit(user(S, ts, HOME, 'and the rate limits?')); + emit(asst(S, ts, HOME, [tool('WebFetch', { url: 'https://example.com/limits' })], 400)); +} + +for (const [k, ls] of bySession) fs.writeFileSync(path.join(PROJ, `${k}.jsonl`), ls.join('\n') + '\n'); + +// --- commits carrying that work --------------------------------------------- +const COMMITS = { + 'acme-api': [ + ['src/routes/orders.ts', 'orders: handle partial refunds', 2], + ['src/lib/auth.ts', 'auth: rotate signing keys', 5], + ['src/db/schema.sql', 'schema: add refund_reason', 9], + ['tests/orders.test.ts', 'tests: cover partial refunds', 16], + ], + 'acme-web': [ + ['app/checkout/page.tsx', 'checkout: inline tax breakdown', 4], + ['app/cart/state.ts', 'cart: fix stale totals', 11], + ], + 'billing-service': [['internal/invoice/render.go', 'invoice: round to minor units', 8]], + 'infra-terraform': [['envs/prod/main.tf', 'prod: bump rds instance class', 13]], +}; +// Date each commit to the day the session that produced it ran, so the +// "N of your M active days ended in a commit" line reflects a real rhythm +// rather than a fixture written all at once. +for (const [repo, cs] of Object.entries(COMMITS)) { + const dir = path.join(HOME, 'code', repo); + for (const [f, msg, back] of cs) { + fs.appendFileSync(path.join(dir, f), 'changed\n'); + const when = day(back); + execFileSync('git', ['-C', dir, 'add', '-A'], { stdio: 'ignore' }); + execFileSync('git', ['-C', dir, 'commit', '-qm', msg], { + stdio: 'ignore', + env: { ...process.env, GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when }, + }); + } +} +console.log(HOME); diff --git a/plugins/receipts/assets/sample-receipt.png b/plugins/receipts/assets/sample-receipt.png new file mode 100644 index 00000000..d0be9f1e Binary files /dev/null and b/plugins/receipts/assets/sample-receipt.png differ