A plain-English receipt after every turn: files created, changed and deleted, by tool or via shell, with failed attempts listed apart.

Summarizes files created, edited and deleted during a tracked turn. It separates successful changes from failed attempts.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/changes-receipt
claude plugin test plugins/changes-receipthooks/changes-receipt.mjs 1022 lines1// Changes Receipt: a plain-English receipt after every turn of the files that
2// were created, changed and deleted.
3//
4// Two sources, reconciled at the end of each main-loop turn:
5// 1. tool calls: successful Edit / Write / NotebookEdit (subagents' included),
6// with denied, errored and no-op calls kept apart as "attempted but not
7// applied";
8// 2. a snapshot taken at turn.start and again at turn.complete: in a git repo
9// `git status --porcelain=v1 -z --untracked-files=all` plus a content hash
10// (`git hash-object`) of every dirty or untracked file, so a pre-existing
11// uncommitted change that did not move during the turn is left out;
12// outside git, a bounded size + mtime walk of the folder.
13// A file that changed during the turn with no tool call behind it is "via
14// shell". The end-of-turn line is returned from turn.complete; `/receipt`
15// toggles a pane with the full list; the model can call `changes_receipt`.
16
17import { update } from "claude-code";
18
19const PLUGIN = "changes-receipt";
20const PANE = "changes-receipt";
21const TITLE = "Changes Receipt";
22const TOOL = "changes_receipt";
23export const TOOL_FULL = `mcp__${PLUGIN}__${TOOL}`;
24
25export const KEEP = 20; // receipts kept in the session
26export const WALK_CAP = 3000; // files a non-git walk records
27export const GIT_CAP = 3000; // dirty + untracked entries a git snapshot hashes
28const DIR_CAP = 1500; // folders a non-git walk lists
29const LINES_MAX_BYTES = 1_000_000;
30const COUNT_FILES_MAX = 60;
31
32export const SKIP_DIRS = new Set([
33 "node_modules",
34 ".git",
35 "dist",
36 "build",
37 ".next",
38 ".nuxt",
39 ".turbo",
40 ".cache",
41 "target",
42 "__pycache__",
43 ".venv",
44 "venv",
45 ".hg",
46 ".svn",
47 "coverage",
48]);
49const WATCHED = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
50const GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" };
51
52const receiptsRef = { plugin: "changes-receipt", key: "receipts" };
53const viewRef = { plugin: "changes-receipt", key: "view" };
54const metaRef = { plugin: "changes-receipt", key: "meta" };
55
56const DEFAULT_VIEW = { count: 5, open: {} };
57const DEFAULT_META = { enabled: true, turns: 0, lastTurn: 0, lastEmpty: false };
58
59export const ALWAYS_NOTES = [
60 "Shell changes outside the session folder cannot be detected; Edit and Write outside it are listed with full paths.",
61 "Anything else that writes files during a turn (your editor, a watcher) also shows up as via shell.",
62];
63
64export const HELP = [
65 "Changes Receipt: after each turn, one line under the answer counts the files created, changed and deleted.",
66 "",
67 "/receipt open or close the pane (last turn in full, earlier turns collapsible)",
68 "/receipt last print the last receipt as plain text, good for pasting",
69 "/receipt turns <n> list the last n receipts in the pane (1-20)",
70 "/receipt off | on stop or resume tracking and the end-of-turn line",
71 "/receipt close close the pane",
72 "/receipt help this help",
73 "",
74 "Or just ask Claude: what did you change?",
75 "",
76 "Pre-existing uncommitted changes that did not change during the turn are left out.",
77 ...ALWAYS_NOTES,
78].join("\n");
79
80// ---- small helpers -------------------------------------------------------------
81
82export const oneLine = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
83export const clip = (s, n) => {
84 const t = String(s ?? "");
85 return t.length > n ? `${t.slice(0, Math.max(0, n - 3))}...` : t;
86};
87const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
88
89export function canon(p, cwd = "/") {
90 if (typeof p !== "string") return null;
91 let s = p.trim();
92 if (!s) return null;
93 if (!s.startsWith("/")) s = `${String(cwd || "/").replace(/\/+$/, "")}/${s}`;
94 const parts = [];
95 for (const seg of s.split("/")) {
96 if (seg === "" || seg === ".") continue;
97 if (seg === "..") parts.pop();
98 else parts.push(seg);
99 }
100 let out = `/${parts.join("/")}`;
101 // macOS: /tmp and /var are links into /private; spell both one way.
102 if (/^\/private\/(tmp|var|etc)(\/|$)/.test(out)) out = out.slice("/private".length);
103 return out;
104}
105
106export function isInside(abs, cwd) {
107 const c = canon(cwd);
108 return abs === c || abs.startsWith(c === "/" ? "/" : `${c}/`);
109}
110
111export function displayPath(abs, cwd) {
112 const c = canon(cwd);
113 if (abs === c) return ".";
114 if (c === "/") return abs;
115 return abs.startsWith(`${c}/`) ? abs.slice(c.length + 1) : abs;
116}
117
118const parentOf = (rel) => {
119 const i = rel.lastIndexOf("/");
120 return i < 0 ? "" : rel.slice(0, i);
121};
122
123export function isSkipped(rel) {
124 return rel.split("/").some((seg) => SKIP_DIRS.has(seg));
125}
126
127export function countLines(text) {
128 const t = String(text ?? "");
129 if (t === "") return 0;
130 const n = t.split("\n").length;
131 return t.endsWith("\n") ? n - 1 : n;
132}
133
134export function patchCounts(patch) {
135 let added = 0;
136 let removed = 0;
137 for (const h of patch ?? []) {
138 for (const line of h?.lines ?? []) {
139 if (line.startsWith("+")) added += 1;
140 else if (line.startsWith("-")) removed += 1;
141 }
142 }
143 return { added, removed };
144}
145
146export function isNoticeText(text) {
147 return /^\s*<(task-notification|agent-message|background-task|system-reminder)\b/.test(String(text ?? ""));
148}
149
150// ---- tool calls ----------------------------------------------------------------
151
152const DENIED = /doesn.t want to proceed|was rejected|rejected by|denied|not allowed|permission/i;
153
154function errorReason(text) {
155 const t = oneLine(String(text ?? "").replace(/<\/?tool_use_error>/g, ""));
156 if (!t) return "failed";
157 if (DENIED.test(t)) return `denied: ${clip(t, 70)}`;
158 const first = t.split(/(?<=\.)\s/)[0] || t;
159 return `failed: ${clip(first, 80)}`;
160}
161
162// One tool call -> one op: { tool, abs, agentId, ok, created, added, removed, counted, reason, noop }.
163export function toolOpFrom(call, outcome, cwd) {
164 if (!call || !WATCHED.has(call.tool)) return null;
165 const raw = call.tool === "NotebookEdit" ? call.notebook_path : call.file_path;
166 const abs = canon(raw, cwd);
167 if (!abs) return null;
168 const base = { tool: call.tool, abs, agentId: call.agentId ? String(call.agentId) : "" };
169 if (!outcome) return { ...base, ok: false, reason: "failed: no result" };
170 if (outcome.deny !== undefined) return { ...base, ok: false, reason: `denied: ${clip(oneLine(outcome.deny), 70)}` };
171 if (outcome.isError) return { ...base, ok: false, reason: errorReason(outcome.text ?? outcome.result) };
172 const res = outcome.result && typeof outcome.result === "object" ? outcome.result : {};
173 if (res.staged) return { ...base, ok: false, reason: "held for review, not written" };
174 if (call.tool === "NotebookEdit" && res.error) return { ...base, ok: false, reason: errorReason(res.error) };
175
176 let created = false;
177 if (call.tool === "Write") {
178 created = res.type === "create";
179 const content = typeof res.content === "string" ? res.content : call.content;
180 if (res.type === "update" && typeof res.originalFile === "string" && res.originalFile === content) {
181 return { ...base, ok: false, noop: true, reason: "no change: same content" };
182 }
183 }
184 let counted = false;
185 let added = 0;
186 let removed = 0;
187 if (Array.isArray(res.structuredPatch)) {
188 ({ added, removed } = patchCounts(res.structuredPatch));
189 counted = true;
190 if (call.tool !== "Write" && res.structuredPatch.length === 0) {
191 return { ...base, ok: false, noop: true, reason: "no change" };
192 }
193 if (call.tool === "Write" && created && added === 0) added = countLines(res.content ?? call.content);
194 } else if (call.tool === "Write" && created) {
195 added = countLines(call.content);
196 counted = true;
197 }
198 return { ...base, ok: true, created, added, removed, counted };
199}
200
201// ---- git snapshot --------------------------------------------------------------
202
203export function parsePorcelainZ(out) {
204 const toks = String(out ?? "").split("\0");
205 const entries = [];
206 for (let i = 0; i < toks.length; i++) {
207 const t = toks[i];
208 if (t.length < 4) continue;
209 const x = t[0];
210 const y = t[1];
211 const path = t.slice(3);
212 if (x === "R" || x === "C" || y === "R" || y === "C") {
213 entries.push({ x, y, path, from: toks[i + 1] ?? "" });
214 i += 1;
215 } else entries.push({ x, y, path });
216 }
217 return entries;
218}
219
220// path (root-relative) -> { code, exists }
221export function entryStates(entries) {
222 const m = new Map();
223 for (const e of entries) {
224 const missing = e.y === "D" || (e.x === "D" && e.y === " ");
225 m.set(e.path, { code: e.x + e.y, exists: !missing });
226 if (e.from && (e.x === "R" || e.y === "R") && !m.has(e.from)) m.set(e.from, { code: "R<", exists: false });
227 }
228 return m;
229}
230
231export function parseLsTreeZ(out) {
232 const m = new Map();
233 for (const rec of String(out ?? "").split("\0")) {
234 const tab = rec.indexOf("\t");
235 if (tab < 0) continue;
236 const [, , hash] = rec.slice(0, tab).split(" ");
237 if (hash) m.set(rec.slice(tab + 1), hash);
238 }
239 return m;
240}
241
242export function parseNumstatZ(out) {
243 const m = new Map();
244 for (const rec of String(out ?? "").split("\0")) {
245 const parts = rec.split("\t");
246 if (parts.length < 3) continue;
247 const [a, d, ...rest] = parts;
248 const p = rest.join("\t").replace(/^\n+/, "");
249 if (a === "-" || d === "-") m.set(p, null);
250 else m.set(p, { added: Number(a) || 0, removed: Number(d) || 0 });
251 }
252 return m;
253}
254
255async function hashPaths(io, top, paths) {
256 const out = new Map();
257 const ok = paths.filter((p) => !p.includes("\n"));
258 if (ok.length === 0) return out;
259 try {
260 const r = await io.run(["git", "hash-object", "--stdin-paths"], { cwd: top, stdin: `${ok.join("\n")}\n` });
261 const lines = r.stdout.split("\n").filter(Boolean);
262 if (r.exitCode === 0 && lines.length === ok.length) {
263 ok.forEach((p, i) => out.set(p, lines[i].trim()));
264 return out;
265 }
266 } catch {
267 // fall through to one at a time
268 }
269 for (const p of ok.slice(0, 200)) {
270 try {
271 const r = await io.run(["git", "hash-object", "--", p], { cwd: top });
272 if (r.exitCode === 0) out.set(p, r.stdout.trim());
273 } catch {
274 // unknown hash: compared as unknown
275 }
276 }
277 return out;
278}
279
280async function lsTree(io, top, rev, paths) {
281 const out = new Map();
282 if (!rev || paths.length === 0) return out;
283 for (let i = 0; i < paths.length; i += 400) {
284 const chunk = paths.slice(i, i + 400);
285 try {
286 const r = await io.run(["git", "--literal-pathspecs", "ls-tree", "-z", rev, "--", ...chunk], { cwd: top });
287 if (r.exitCode === 0) for (const [p, h] of parseLsTreeZ(r.stdout)) out.set(p, h);
288 } catch {
289 // treated as absent
290 }
291 }
292 return out;
293}
294
295export async function gitSnapshot(io, cwd) {
296 let top;
297 try {
298 const r = await io.run(["git", "rev-parse", "--show-toplevel"], { cwd });
299 if (r.exitCode !== 0) return null;
300 top = r.stdout.trim();
301 } catch {
302 return null;
303 }
304 if (!top) return null;
305 const headR = await io.run(["git", "rev-parse", "--verify", "-q", "HEAD"], { cwd });
306 const head = headR.exitCode === 0 ? headR.stdout.trim() : "";
307 const st = await io.run(
308 ["git", "-c", "core.quotepath=off", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
309 { cwd, timeoutMs: 20000 },
310 );
311 if (st.exitCode !== 0) return null;
312 const all = [...entryStates(parsePorcelainZ(st.stdout))].filter(([p]) => !isSkipped(p));
313 const capped = all.length > GIT_CAP;
314 const states = new Map(all.slice(0, GIT_CAP).map(([p, s]) => [p, { ...s, hash: null }]));
315 const hashes = await hashPaths(
316 io,
317 top,
318 [...states].filter(([, s]) => s.exists).map(([p]) => p),
319 );
320 for (const [p, h] of hashes) states.get(p).hash = h;
321 return { mode: "git", cwd, top, head, states, capped };
322}
323
324// Start and end states of every candidate path -> net changes (root-relative).
325export function classifyGit(cands, start, end, startTree, endTree) {
326 const changes = [];
327 for (const p of cands) {
328 const s = start.states.get(p);
329 const e = end.states.get(p);
330 const a = s ? { exists: s.exists, hash: s.hash, dirty: true } : { exists: startTree.has(p), hash: startTree.get(p) ?? null, dirty: false };
331 const b = e ? { exists: e.exists, hash: e.hash } : { exists: endTree.has(p), hash: endTree.get(p) ?? null };
332 if (!a.exists && !b.exists) continue;
333 if (a.exists && b.exists) {
334 if (a.hash && b.hash && a.hash === b.hash) continue;
335 if (!a.hash && !b.hash) continue; // nothing to compare: say nothing rather than guess
336 changes.push({ rel: p, kind: "changed", startHash: a.hash, endHash: b.hash, startClean: !a.dirty });
337 } else if (b.exists) changes.push({ rel: p, kind: "created", startHash: null, endHash: b.hash, startClean: !a.dirty });
338 else changes.push({ rel: p, kind: "deleted", startHash: a.hash, endHash: null, startClean: !a.dirty });
339 }
340 return changes;
341}
342
343// Pair a deleted path with a created one when they hold the same content.
344export function pairRenames(changes, keyOf) {
345 const created = changes.filter((c) => c.kind === "created");
346 const deleted = changes.filter((c) => c.kind === "deleted");
347 const byKey = new Map();
348 for (const c of created) {
349 const k = keyOf(c, "end");
350 if (!k) continue;
351 byKey.set(k, byKey.has(k) ? null : c); // ambiguous: no pairing
352 }
353 const gone = new Set();
354 const out = [];
355 for (const d of deleted) {
356 const k = keyOf(d, "start");
357 const c = k ? byKey.get(k) : undefined;
358 if (c && !gone.has(c)) {
359 gone.add(c);
360 gone.add(d);
361 out.push({ ...c, kind: "renamed", fromRel: d.rel });
362 }
363 }
364 return [...changes.filter((c) => !gone.has(c)), ...out];
365}
366
367export async function gitDiff(io, start, end) {
368 const cands = new Set([...start.states.keys(), ...end.states.keys()]);
369 if (start.head && end.head && start.head !== end.head) {
370 try {
371 const d = await io.run(["git", "diff", "--name-only", "--no-renames", "-z", start.head, end.head, "--", "."], { cwd: end.cwd });
372 if (d.exitCode === 0) for (const p of d.stdout.split("\0").filter(Boolean)) if (!isSkipped(p)) cands.add(p);
373 } catch {
374 // the moved HEAD's own changes are missed
375 }
376 }
377 const startTree = await lsTree(io, start.top, start.head, [...cands].filter((p) => !start.states.has(p)));
378 const endTree = await lsTree(io, end.top, end.head, [...cands].filter((p) => !end.states.has(p)));
379 let changes = classifyGit(cands, start, end, startTree, endTree);
380 changes = pairRenames(changes, (c, side) => (side === "end" ? c.endHash : c.startHash));
381
382 // Line counts: a file clean at turn start diffs against the start HEAD.
383 const numPaths = changes.filter((c) => c.startClean && start.head && (c.kind === "changed" || c.kind === "deleted")).map((c) => c.rel);
384 if (numPaths.length > 0) {
385 try {
386 const r = await io.run(
387 ["git", "--literal-pathspecs", "diff", "--numstat", "--no-renames", "-z", start.head, "--", ...numPaths],
388 { cwd: start.top },
389 );
390 if (r.exitCode === 0) {
391 const nums = parseNumstatZ(r.stdout);
392 for (const c of changes) {
393 const n = nums.get(c.rel);
394 if (n) Object.assign(c, n);
395 }
396 }
397 } catch {
398 // counts omitted
399 }
400 }
401 const top = start.top;
402 return changes.map((c) => ({
403 abs: canon(`${top}/${c.rel}`),
404 kind: c.kind,
405 fromAbs: c.fromRel ? canon(`${top}/${c.fromRel}`) : undefined,
406 added: c.added,
407 removed: c.removed,
408 }));
409}
410
411// ---- non-git walk --------------------------------------------------------------
412
413export async function walkSnapshot(io, cwd, cap = WALK_CAP) {
414 const files = new Map();
415 const done = new Set();
416 let capped = false;
417 let listed = 0;
418 const queue = [""];
419 while (queue.length > 0) {
420 const rel = queue.shift();
421 if (listed >= DIR_CAP) {
422 capped = true;
423 break;
424 }
425 listed += 1;
426 let entries;
427 try {
428 entries = await io.list(rel ? `${cwd}/${rel}` : cwd);
429 } catch {
430 continue;
431 }
432 let complete = true;
433 for (const en of entries) {
434 const p = rel ? `${rel}/${en.name}` : en.name;
435 if (en.kind === "dir") {
436 if (!SKIP_DIRS.has(en.name) && !en.isLink) queue.push(p);
437 continue;
438 }
439 if (en.kind !== "file") continue;
440 if (files.size >= cap) {
441 capped = true;
442 complete = false;
443 break;
444 }
445 files.set(p, { size: en.size, mtime: en.mtimeMs });
446 }
447 if (complete) done.add(rel);
448 if (capped) break;
449 }
450 return { mode: "walk", cwd, files, done, capped };
451}
452
453export function diffWalk(start, end) {
454 const covered = (rel) => {
455 const dir = parentOf(rel);
456 return (start.done.has(dir) || !start.capped) && (end.done.has(dir) || !end.capped);
457 };
458 const changes = [];
459 for (const p of new Set([...start.files.keys(), ...end.files.keys()])) {
460 if (!covered(p)) continue;
461 const a = start.files.get(p);
462 const b = end.files.get(p);
463 if (a && b) {
464 if (a.size !== b.size || a.mtime !== b.mtime) changes.push({ rel: p, kind: "changed", a, b });
465 } else if (b) changes.push({ rel: p, kind: "created", a, b });
466 else changes.push({ rel: p, kind: "deleted", a, b });
467 }
468 // mv keeps size and mtime: a deleted + created pair with both equal is a rename.
469 const paired = pairRenames(changes, (c, side) => {
470 const s = side === "end" ? c.b : c.a;
471 return s && s.size > 0 ? `${s.size}:${s.mtime}` : null;
472 });
473 return paired.map((c) => ({
474 abs: canon(`${start.cwd}/${c.rel}`),
475 kind: c.kind,
476 fromAbs: c.fromRel ? canon(`${start.cwd}/${c.fromRel}`) : undefined,
477 }));
478}
479
480// ---- snapshots, either kind -------------------------------------------------------
481
482export async function takeSnapshot(io, cwd) {
483 try {
484 const g = await gitSnapshot(io, cwd);
485 if (g) return g;
486 } catch {
487 // fall back to the walk
488 }
489 try {
490 return await walkSnapshot(io, cwd);
491 } catch {
492 return null;
493 }
494}
495
496export async function diffSnapshots(io, start, end) {
497 if (!start || !end || start.mode !== end.mode) return null;
498 if (start.mode === "git") return gitDiff(io, start, end);
499 return diffWalk(start, end);
500}
501
502// ---- reconcile -------------------------------------------------------------------
503
504const KIND_ORDER = { created: 0, changed: 1, renamed: 2, deleted: 3 };
505
506const sourceLabel = (op) => (op.agentId ? `${op.tool} (subagent)` : op.tool);
507
508// fsChanges: [{ abs, kind, fromAbs?, added?, removed? }] or null (no snapshot)
509// ops: tool ops of the turn; exists: abs -> boolean for tool-only paths now.
510export function reconcile({ fsChanges, ops, cwd, exists = {} }) {
511 const okOps = new Map();
512 for (const op of ops) {
513 if (!op.ok) continue;
514 if (!okOps.has(op.abs)) okOps.set(op.abs, []);
515 okOps.get(op.abs).push(op);
516 }
517 const sumOps = (list) => {
518 if (!list || list.length === 0 || list.some((o) => !o.counted)) return {};
519 return { added: list.reduce((s, o) => s + o.added, 0), removed: list.reduce((s, o) => s + o.removed, 0) };
520 };
521 const items = [];
522 const seen = new Set();
523 for (const c of fsChanges ?? []) {
524 const list = [...(okOps.get(c.abs) ?? []), ...(c.fromAbs ? okOps.get(c.fromAbs) ?? [] : [])];
525 const sources = list.length > 0 ? [...new Set(list.map(sourceLabel))] : ["shell"];
526 seen.add(c.abs);
527 if (c.fromAbs) seen.add(c.fromAbs);
528 let added = c.added;
529 let removed = c.removed;
530 if (added === undefined && removed === undefined && c.kind !== "renamed" && c.kind !== "deleted") {
531 ({ added, removed } = sumOps(okOps.get(c.abs)));
532 }
533 const item = { kind: c.kind, path: displayPath(c.abs, cwd), sources };
534 if (c.fromAbs) item.from = displayPath(c.fromAbs, cwd);
535 if (added !== undefined) item.added = added;
536 if (removed !== undefined) item.removed = removed;
537 if (!isInside(c.abs, cwd)) item.outside = true;
538 items.push(item);
539 }
540 // Tool changes the snapshot did not see: outside the folder, ignored by git,
541 // past the cap, or no snapshot at all. A file gone again is left out.
542 for (const [abs, list] of okOps) {
543 if (seen.has(abs)) continue;
544 if (exists[abs] === false) continue;
545 const { added, removed } = sumOps(list);
546 const item = {
547 kind: list[0].created ? "created" : "changed",
548 path: displayPath(abs, cwd),
549 sources: [...new Set(list.map(sourceLabel))],
550 };
551 if (added !== undefined) item.added = added;
552 if (removed !== undefined) item.removed = removed;
553 if (!isInside(abs, cwd)) item.outside = true;
554 items.push(item);
555 }
556 items.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.path.localeCompare(b.path));
557
558 const attempts = [];
559 const tried = new Set();
560 for (const op of ops) {
561 if (op.ok || okOps.has(op.abs)) continue;
562 const key = `${op.abs}\0${op.tool}\0${op.reason}`;
563 if (tried.has(key)) continue;
564 tried.add(key);
565 const a = { path: displayPath(op.abs, cwd), tool: op.tool, reason: op.reason };
566 if (op.agentId) a.bySubagent = true;
567 attempts.push(a);
568 }
569 return { items, attempts };
570}
571
572// Paths only the tool calls know of, which the caller checks for existence.
573export function toolOnlyPaths(fsChanges, ops, cwd) {
574 const seen = new Set();
575 for (const c of fsChanges ?? []) {
576 seen.add(c.abs);
577 if (c.fromAbs) seen.add(c.fromAbs);
578 }
579 const out = new Set();
580 for (const op of ops) if (op.ok && !seen.has(op.abs)) out.add(op.abs);
581 return [...out];
582}
583
584// ---- words -------------------------------------------------------------------------
585
586export function counts(rc) {
587 const c = { created: 0, changed: 0, deleted: 0, renamed: 0, shell: 0, attempts: rc.attempts.length };
588 for (const it of rc.items) {
589 c[it.kind] += 1;
590 if (it.sources.length === 1 && it.sources[0] === "shell") c.shell += 1;
591 }
592 return c;
593}
594
595export function summaryLine(rc) {
596 if (!rc || (rc.items.length === 0 && rc.attempts.length === 0)) return "";
597 const c = counts(rc);
598 const parts = [];
599 if (c.created) parts.push(`${c.created} created`);
600 if (c.changed) parts.push(`${c.changed} changed`);
601 if (c.renamed) parts.push(`${c.renamed} renamed`);
602 if (c.deleted) parts.push(`${c.deleted} deleted`);
603 if (rc.items.length === 0) parts.push("nothing changed");
604 if (c.shell) parts.push(`${c.shell} via shell`);
605 if (c.attempts) parts.push(`${c.attempts} not applied`);
606 return `Receipt · ${parts.join(" · ")} · /receipt for details`;
607}
608
609export function lineCounts(it) {
610 const a = it.added ?? 0;
611 const r = it.removed ?? 0;
612 if (a && r) return `${plural(a, "line")} added, ${r} removed`;
613 if (a) return `${plural(a, "line")} added`;
614 if (r) return `${plural(r, "line")} removed`;
615 return "";
616}
617
618export function sourceText(it) {
619 if (it.sources.length === 1 && it.sources[0] === "shell") return "via shell";
620 return `by ${it.sources.join(", ")}`;
621}
622
623const GROUPS = [
624 ["created", "Created"],
625 ["changed", "Changed"],
626 ["renamed", "Renamed"],
627 ["deleted", "Deleted"],
628];
629
630export function receiptText(rc) {
631 if (!rc) return "No receipt yet: no turn has changed a file in this session.";
632 const lines = [`Turn ${rc.n} receipt${rc.prompt ? ` for "${clip(oneLine(rc.prompt), 70)}"` : ""}`];
633 if (rc.items.length === 0 && rc.attempts.length === 0) lines.push("No files were created, changed or deleted.");
634 for (const [kind, label] of GROUPS) {
635 const list = rc.items.filter((it) => it.kind === kind);
636 if (list.length === 0) continue;
637 lines.push(`${label} (${list.length})`);
638 for (const it of list) {
639 const name = it.from ? `${it.from} to ${it.path}` : it.path;
640 const lc = lineCounts(it);
641 lines.push(`- ${name}${lc ? `, ${lc}` : ""}, ${sourceText(it)}`);
642 }
643 }
644 if (rc.attempts.length > 0) {
645 lines.push(`Attempted but not applied (${rc.attempts.length})`);
646 for (const a of rc.attempts) lines.push(`- ${a.path}, ${a.tool}${a.bySubagent ? " (subagent)" : ""} ${a.reason}`);
647 }
648 for (const n of rc.notes) lines.push(`Note: ${n}`);
649 return lines.join("\n");
650}
651
652// ---- the plugin ---------------------------------------------------------------------
653
654export function register(on) {
655 let current = null; // { id, n, prompt, at, cwd, snap, ops }
656 let between = []; // ops landing while no main turn runs (a background subagent)
657
658 on("session.start", async ($, e, next) => {
659 const started = await next(e);
660 try {
661 await $.command.register({
662 name: "receipt",
663 description: "Changes Receipt: files this turn created, changed and deleted",
664 argumentHint: "[last|turns <n>|on|off|close|help]",
665 immediate: true,
666 });
667 } catch {
668 // registered on an earlier load
669 }
670 try {
671 await $.tool.register({
672 name: TOOL,
673 description:
674 "Show the receipt of files created, changed, renamed and deleted in a recent turn of this session, " +
675 "from Edit/Write/NotebookEdit calls (subagents included) and a git or file snapshot that catches shell changes. " +
676 "Also lists edits that were attempted but not applied. Call it when the user asks what you changed, " +
677 "which files were touched, or for a receipt or summary of changes. Pass the answer on as written.",
678 inputSchema: {
679 type: "object",
680 properties: {
681 turn: {
682 description: '"last" (default) for the latest receipt, "all" for every kept receipt, or a turn number.',
683 anyOf: [{ type: "string" }, { type: "integer" }],
684 },
685 },
686 },
687 });
688 } catch {
689 // registered on an earlier load
690 }
691 let enabled = true;
692 try {
693 enabled = (await $.store.get("enabled")) !== false;
694 } catch {
695 // default on
696 }
697 await update($, metaRef, (m) => ({ ...DEFAULT_META, ...(m ?? {}), enabled }));
698 return started;
699 });
700
701 on("turn.start", async ($, e, next) => {
702 if (e.agentId) return next(e); // helper turns must not replace the main receipt
703 try {
704 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
705 const n = (meta.turns ?? 0) + 1;
706 await $.state.set(metaRef, { ...meta, turns: n });
707 if (meta.enabled === false) {
708 current = null;
709 } else {
710 const cwd = await $.session.cwd();
711 const io = {
712 run: (argv, opts = {}) => $.process.run(argv, { timeoutMs: 15000, ...opts, env: GIT_ENV }),
713 list: (path) => $.fs.list(path),
714 };
715 const prompt = isNoticeText(e.text) ? "(background task finished)" : e.text ?? "";
716 const turn = { id: e.turnId, n, prompt, at: await $.clock.now(), cwd, snap: null, ops: between };
717 current = turn; // keep direct writes even when the optional snapshot fails
718 between = [];
719 turn.snap = await takeSnapshot(io, cwd);
720 }
721 } catch {
722 current = null;
723 }
724 return next(e);
725 });
726
727 on("tool.call", async ($, e, next) => {
728 if (!WATCHED.has(e.tool)) return next(e);
729 const result = await next(e);
730 try {
731 const cwd = current ? current.cwd : await $.session.cwd();
732 const op = toolOpFrom(e, result, cwd);
733 if (op) {
734 if (current) current.ops.push(op);
735 else {
736 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
737 if (meta.enabled !== false) between = [...between, op].slice(-200);
738 }
739 }
740 } catch {
741 // the receipt is a record, never a reason to fail the call
742 }
743 return result;
744 });
745
746 on("turn.complete", async ($, e, next) => {
747 const result = await next(e);
748 if (e.agentId) return result; // a subagent's run: its calls ride the main turn
749 let turn = current;
750 current = null;
751 if (!turn && between.length) {
752 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
753 if (meta.enabled !== false) {
754 turn = { id: e.turnId, n: meta.turns || 1, prompt: "Recovered tool activity", at: await $.clock.now(), cwd: await $.session.cwd(), snap: null, ops: between };
755 between = [];
756 }
757 }
758 if (!turn) return result;
759 try {
760 const cwd = turn.cwd;
761 const io = {
762 run: (argv, opts = {}) => $.process.run(argv, { timeoutMs: 15000, ...opts, env: GIT_ENV }),
763 list: (path) => $.fs.list(path),
764 };
765 const notes = [];
766 let fsChanges = null;
767 if (!turn.snap) {
768 notes.push("No folder snapshot this turn: only Edit and Write calls are listed, shell changes are not.");
769 } else {
770 let endSnap = null;
771 try {
772 endSnap = await takeSnapshot(io, cwd);
773 fsChanges = await diffSnapshots(io, turn.snap, endSnap);
774 } catch {
775 // Snapshot enrichment must never discard confirmed tool writes.
776 fsChanges = null;
777 }
778 if (fsChanges === null) notes.push("The folder snapshot failed at the end of the turn: shell changes are not listed.");
779 if (turn.snap.mode === "walk") notes.push("Not a git folder: shell changes were found by file size and time.");
780 if (turn.snap.capped || (endSnap && endSnap.capped)) {
781 notes.push(`Snapshot capped at ${turn.snap.mode === "git" ? GIT_CAP : WALK_CAP} files: shell changes past the cap were not checked.`);
782 }
783 }
784 const exists = {};
785 for (const abs of toolOnlyPaths(fsChanges, turn.ops, cwd)) {
786 try {
787 exists[abs] = await $.fs.exists(abs);
788 } catch {
789 exists[abs] = true;
790 }
791 }
792 const { items, attempts } = reconcile({ fsChanges, ops: turn.ops, cwd, exists });
793 // Line counts for created files the tools did not count (shell-made).
794 let budget = COUNT_FILES_MAX;
795 for (const it of items) {
796 if (it.kind !== "created" || it.added !== undefined || budget <= 0) continue;
797 budget -= 1;
798 try {
799 const abs = canon(it.path, cwd);
800 const st = await $.fs.stat(abs);
801 if (st.kind !== "file" || st.size > LINES_MAX_BYTES) continue;
802 const text = await $.fs.read(abs);
803 if (typeof text === "string" && !text.includes("\u0000")) it.added = countLines(text);
804 } catch {
805 // counts omitted
806 }
807 }
808 const empty = items.length === 0 && attempts.length === 0;
809 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
810 await $.state.set(metaRef, { ...meta, lastTurn: turn.n, lastEmpty: empty });
811 if (empty) return result;
812 const rc = {
813 id: String(turn.id ?? `t${turn.n}`),
814 n: turn.n,
815 prompt: clip(oneLine(turn.prompt), 200),
816 at: turn.at,
817 mode: turn.snap ? turn.snap.mode : "none",
818 notes,
819 items,
820 attempts,
821 };
822 await update($, receiptsRef, (list) => [...(list ?? []), rc].slice(-KEEP));
823 return { ...result, text: summaryLine(rc) };
824 } catch {
825 // Retain the evidence for the next completion instead of losing it.
826 between = [...turn.ops, ...between].slice(-200);
827 return result;
828 }
829 });
830
831 // ---- /receipt ----------------------------------------------------------------------
832 on("command.run", { command: "receipt" }, async ($, e) => {
833 const words = String(e.args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
834 const verb = words[0] ?? "";
835 if (verb === "help" || verb === "?") return { text: HELP };
836 if (verb === "last") {
837 const { value: list = [] } = await $.state.get(receiptsRef);
838 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
839 const text = list.length ? receiptText(list[list.length - 1]) : "No saved receipt yet. Tracking may have been off, started after the changes, or failed to capture the turn.";
840 return { text: meta.enabled === false ? "Tracking is off. Use /receipt on before your next task.\n\n" + text : text };
841 }
842 if (verb === "off" || verb === "on") {
843 const enabled = verb === "on";
844 await update($, metaRef, (m) => ({ ...DEFAULT_META, ...(m ?? {}), enabled }));
845 await $.store.set("enabled", enabled);
846 if (!enabled) {
847 current = null;
848 between = [];
849 }
850 return {
851 text: enabled
852 ? "Changes Receipt on: each turn ends with a receipt line."
853 : "Changes Receipt off: no snapshots, no tracking, no end-of-turn line. /receipt on to resume.",
854 };
855 }
856 if (verb === "turns") {
857 const n = Number(words[1]);
858 if (!Number.isInteger(n) || n < 1 || n > KEEP) return { text: `Usage /receipt turns <1-${KEEP}>` };
859 await update($, viewRef, (v) => ({ ...DEFAULT_VIEW, ...(v ?? {}), count: n }));
860 const panes = await $.ui.panes();
861 if (!panes.some((p) => p.id === PANE)) await $.ui.open({ id: PANE, title: TITLE });
862 return { text: `Changes Receipt lists the last ${plural(n, "receipt")}.` };
863 }
864 const panes = await $.ui.panes();
865 const pane = panes.find((p) => p.id === PANE);
866 if (verb === "close" || (verb === "" && pane && pane.isShown !== false)) {
867 if (pane) await $.ui.close({ id: PANE });
868 return { text: "Changes Receipt closed." };
869 }
870 if (verb !== "" && verb !== "open") return { text: `Unknown option "${verb}".\n\n${HELP}` };
871 const opened = await $.ui.open({ id: PANE, title: TITLE });
872 if (opened && opened.isPlaced === false) {
873 const { value: list = [] } = await $.state.get(receiptsRef);
874 return { text: `Changes Receipt could not dock (${opened.reason}). Here is the last receipt:\n\n${receiptText(list[list.length - 1])}` };
875 }
876 return { text: "Changes Receipt open. /receipt again closes it." };
877 });
878
879 // ---- the model's tool ------------------------------------------------------------
880 on("tool.call", { tool: "mcp__changes-receipt__changes_receipt" }, async ($, e) => {
881 const { value: list = [] } = await $.state.get(receiptsRef);
882 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
883 const want = e.turn ?? "last";
884 let text;
885 if (list.length === 0) {
886 text = "No receipt yet: no turn in this session has created, changed or deleted a file (or tried to).";
887 } else if (want === "all") {
888 text = list.map(receiptText).join("\n\n");
889 } else if (want === "last" || want === "" || want === undefined) {
890 const rc = list[list.length - 1];
891 text = receiptText(rc);
892 if (meta.lastEmpty && meta.lastTurn > rc.n) {
893 text = `The most recent finished turn (turn ${meta.lastTurn}) changed no files. The latest receipt is from turn ${rc.n}:\n\n${text}`;
894 }
895 } else {
896 const n = Number(want);
897 const rc = list.find((r) => r.n === n);
898 text = rc
899 ? receiptText(rc)
900 : `No receipt for turn ${want}: it changed no files, or it is older than the last ${KEEP} receipts. Kept turns: ${list.map((r) => r.n).join(", ")}.`;
901 }
902 if (meta.enabled === false) text = `Tracking is off (/receipt on resumes it).\n\n${text}`;
903 return { result: text };
904 });
905
906 // ---- the pane ----------------------------------------------------------------------
907 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
908 const { Box, Text, Button } = $.ui.resolve(e);
909 const { value: list = [] } = await $.state.get(receiptsRef);
910 const { value: view = DEFAULT_VIEW } = await $.state.get(viewRef);
911 const { value: meta = DEFAULT_META } = await $.state.get(metaRef);
912 const width = Math.max(30, e.props?.bodyColumns ?? 80);
913 const dim = (children, key) => Text({ key, dimColor: true, wrap: "truncate", children });
914
915 const rows = [];
916 const head = [];
917 head.push(meta.enabled === false ? "tracking off" : "tracking on");
918 if (list.length) head.push(plural(list.length, "receipt"));
919 if (meta.lastEmpty && list.length && meta.lastTurn > list[list.length - 1].n) head.push(`turn ${meta.lastTurn} changed nothing`);
920 rows.push(dim(head.join(" · "), "head"));
921
922 if (list.length === 0) {
923 rows.push(Text({ key: "empty", children: "No changes yet. After a turn that creates, changes or deletes a file, its receipt shows here." }));
924 }
925
926 const shown = list.slice(-Math.max(1, view.count ?? 5)).reverse();
927 shown.forEach((rc, idx) => {
928 const isOpen = view.open?.[rc.id] ?? idx === 0;
929 const summary = summaryLine(rc).replace(/^Receipt · /, "").replace(/ · \/receipt for details$/, "");
930 rows.push(
931 Box({
932 key: `h:${rc.id}`,
933 flexDirection: "row",
934 gap: 1,
935 marginTop: 1,
936 children: [
937 Button({
938 key: `t:${rc.id}`,
939 label: isOpen ? "-" : "+",
940 plain: true,
941 onPress: () =>
942 update($, viewRef, (v) => {
943 const cur = v ?? DEFAULT_VIEW;
944 return { ...cur, open: { ...(cur.open ?? {}), [rc.id]: !isOpen } };
945 }),
946 }),
947 Text({ bold: true, color: idx === 0 ? "#e8603c" : undefined, children: `Turn ${rc.n}` }),
948 Text({ dimColor: true, wrap: "truncate", children: clip(summary, Math.max(10, width - 14)) }),
949 ],
950 }),
951 );
952 if (!isOpen) return;
953 if (rc.prompt) rows.push(dim(clip(`"${rc.prompt}"`, width), `p:${rc.id}`));
954 for (const [kind, label] of GROUPS) {
955 const its = rc.items.filter((it) => it.kind === kind);
956 if (its.length === 0) continue;
957 rows.push(Text({ key: `g:${rc.id}:${kind}`, bold: true, children: `${label} (${its.length})` }));
958 its.forEach((it, i) => {
959 const name = it.from ? `${it.from} -> ${it.path}` : it.path;
960 const src = sourceText(it);
961 const room = Math.max(8, width - 22 - src.length);
962 rows.push(
963 Box({
964 key: `i:${rc.id}:${kind}:${i}`,
965 flexDirection: "row",
966 gap: 1,
967 children: [
968 Text({ color: MARK_COLOR[kind], children: ` ${MARK[kind]}` }),
969 Text({ wrap: "truncate", children: clip(name, room) }),
970 it.added ? Text({ color: "green", children: `+${it.added}` }) : null,
971 it.removed ? Text({ color: "red", children: `-${it.removed}` }) : null,
972 Text({ dimColor: true, children: src }),
973 ],
974 }),
975 );
976 });
977 }
978 if (rc.attempts.length > 0) {
979 rows.push(Text({ key: `g:${rc.id}:att`, bold: true, color: "yellow", children: `Attempted but not applied (${rc.attempts.length})` }));
980 rc.attempts.forEach((a, i) => {
981 rows.push(
982 Text({
983 key: `a:${rc.id}:${i}`,
984 wrap: "truncate",
985 children: clip(` ! ${a.path} ${a.tool}${a.bySubagent ? " (subagent)" : ""} ${a.reason}`, width),
986 }),
987 );
988 });
989 }
990 for (const [i, n] of rc.notes.entries()) rows.push(dim(clip(`note: ${n}`, width), `n:${rc.id}:${i}`));
991 });
992
993 rows.push(dim(clip("Pre-existing uncommitted changes that did not move this turn are left out.", width), "f1"));
994 rows.push(dim(clip("Shell changes outside this folder are not seen; other programs' writes count as shell.", width), "f2"));
995 rows.push(
996 Box({
997 key: "buttons",
998 flexDirection: "row",
999 gap: 1,
1000 marginTop: 1,
1001 children: [
1002 Button({
1003 key: "copy",
1004 label: "y Copy last",
1005 hotkey: "y",
1006 onPress: async (pe) => {
1007 const { value: now = [] } = await $.state.get(receiptsRef);
1008 const r = await $.ui.copy({ text: receiptText(now[now.length - 1]), surface: pe?.surface });
1009 $.ui.toast(r && r.isCopied ? "Receipt copied." : "Could not copy here. /receipt last prints it.");
1010 },
1011 }),
1012 Button({ key: "close", label: "c Close", hotkey: "c", role: "dismiss", onPress: () => $.ui.close({ id: PANE }) }),
1013 ],
1014 }),
1015 );
1016 return Box({ flexDirection: "column", children: rows });
1017 });
1018}
1019
1020const MARK = { created: "+", changed: "~", renamed: ">", deleted: "-" };
1021const MARK_COLOR = { created: "green", changed: "yellow", renamed: "cyan", deleted: "red" };
1022types/index.d.ts 59 lines1export type ChangesReceiptKind = "created" | "changed" | "deleted" | "renamed";
2
3export type ChangesReceiptItem = {
4 kind: ChangesReceiptKind;
5 /** Relative to the session folder when inside it, else absolute. */
6 path: string;
7 /** For a rename: where it came from. */
8 from?: string;
9 added?: number;
10 removed?: number;
11 /** Edit, Write, NotebookEdit, "Edit (subagent)", or "shell". */
12 sources: string[];
13 outside?: boolean;
14};
15
16export type ChangesReceiptAttempt = {
17 path: string;
18 tool: string;
19 reason: string;
20 bySubagent?: boolean;
21};
22
23export type ChangesReceipt = {
24 id: string;
25 n: number;
26 prompt: string;
27 at: number;
28 mode: "git" | "walk" | "none";
29 notes: string[];
30 items: ChangesReceiptItem[];
31 attempts: ChangesReceiptAttempt[];
32};
33
34export type ChangesReceiptView = {
35 /** How many recent receipts the pane lists. */
36 count: number;
37 /** Per receipt id: expanded (true) or collapsed (false); absent = default. */
38 open: Record<string, boolean>;
39};
40
41export type ChangesReceiptMeta = {
42 enabled: boolean;
43 /** Main-loop turns seen this session. */
44 turns: number;
45 /** The number of the most recent finished turn, and whether it changed nothing. */
46 lastTurn: number;
47 lastEmpty: boolean;
48};
49
50declare module "claude-code" {
51 interface PluginState {
52 "changes-receipt": {
53 receipts: ChangesReceipt[];
54 view: ChangesReceiptView;
55 meta: ChangesReceiptMeta;
56 };
57 }
58}
59