SLOPSHOPPER

changes-receipt

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

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changes-receipt
│ ┃ Changes Receipt ✕ › fix the failing auth test and add an audit log call │ ┃ tracking on · 1 receipt │ ┃ ⏺ Read(src/auth.ts) │ ┃ - Turn 1 1 changed ⎿ Read 6 lines │ ┃ Changed (1) ⏺ Update(src/auth.ts) │ ┃ ~ src/auth.ts by Edit ⎿ Added 2 lines, removed 1 line │ ┃ Pre-existing uncommitted changes that did n… ⏺ Bash(bun test) │ ┃ Shell changes outside this folder are not s… ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ y Copy last ] [ c Close ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ Receipt · 1 changed · /receipt for details │ │ › /receipt │ ⎿ changes-receipt: Changes Receipt open. /receipt again closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes Receipt
tracking on · 1 receipt - Turn 1 1 changed Changed (1) ~ src/auth.ts by Edit Pre-existing uncommitted changes that did not move th... Shell changes outside this folder are not seen; other... [ y Copy last ] [ c Close ]
README

Changes Receipt

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-receipt
Source 2 files
hooks/changes-receipt.mjs 1022 lines
1// 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" };
1022
types/index.d.ts 59 lines
1export 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