A pane and /ledger: what this session changed, per request, and whether it was verified or committed.

hooks/session-ledger.mjs 164 lines1// session-ledger: what this session changed, filed under the request that caused it,
2// and whether each change was checked, committed, or touched again by someone else.
3// /ledger prints it anywhere and opens it as a pane where panes are drawn.
4
5const REQUESTS = { plugin: "session-ledger", key: "requests" };
6const PANE = "ledger";
7const SHOWN = 8;
8const EDIT_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
9const CHECKS = [
10 [/astro build/, "build"],
11 [/npm run probe/, "probe"],
12 [/npm run shot:page/, "page capture"],
13 [/npm run shot\b(?!:)/, "capture"],
14 [/knob-doc\.py --check/, "knob doc"],
15 [/\b(vitest|npm (run )?test|tsc\b|astro check)/, "tests"],
16];
17// Someone else wrote the file if its mtime is this far past our last write.
18const FOREIGN_MS = 3000;
19
20export function register(on) {
21 on("session.start", async ($, e, next) => {
22 const result = await next(e);
23 await $.command.register({ name: "ledger", description: "What this session changed, per request: checked, committed, or not", immediate: true });
24 return result;
25 });
26
27 on("turn.start", async ($, e, next) => {
28 const ask = e.text.trim();
29 if (ask && !ask.startsWith("/")) {
30 const { value: requests = [] } = await $.state.get(REQUESTS);
31 const first = ask.split("\n").find((l) => l.trim()) ?? ask;
32 await $.state.set(REQUESTS, [...requests, { ask: clip(first, 90), at: await $.clock.now(), files: [], checks: [], commits: [] }]);
33 }
34 return next(e);
35 });
36
37 on("tool.call", async ($, e, next) => {
38 const result = await next(e);
39 if (result.deny) return result;
40 if (EDIT_TOOLS.has(e.tool) && !result.isError) {
41 const path = String(e.file_path ?? e.notebook_path ?? "");
42 if (path) await record($, (r, now, by) => ({ ...r, files: [...r.files.filter((f) => f.path !== path), { path, by, at: now }] }), e.agentId);
43 } else if (e.tool === "Bash") {
44 const command = String(e.command ?? "");
45 const check = CHECKS.find(([re]) => re.test(command));
46 if (check && !result.result?.backgroundTaskId) {
47 await record($, (r, now) => ({ ...r, checks: [...r.checks, { what: check[1], ok: !result.isError, at: now }] }), e.agentId);
48 }
49 if (/\bgit commit\b/.test(command) && !result.isError) {
50 const { stdout } = await $.process.run(["git", "log", "-1", "--format=%h%x09%s"]);
51 const [hash, subject = ""] = stdout.trim().split("\t");
52 if (hash) await record($, (r, now) => ({ ...r, commits: [...r.commits.filter((c) => c.subject !== subject), { hash, subject, at: now }] }), e.agentId);
53 }
54 }
55 return result;
56 });
57
58 on("command.run", { command: "ledger" }, async ($) => {
59 await $.ui.open({ id: PANE, title: "Ledger" });
60 const rows = await build($);
61 return { text: rows.map((r) => r.map((p) => p.text).join("")).join("\n") };
62 });
63
64 on("ui.render", { component: "Pane" }, async ($, e, next) => {
65 if (e.requestId !== PANE) return next(e);
66 const { Box, Text } = $.ui.resolve(e);
67 const rows = await build($);
68 return Box({
69 flexDirection: "column",
70 children: rows.map((parts) =>
71 Box({ flexDirection: "row", children: parts.map((p) => Text({ color: p.color, dimColor: p.dim, bold: p.bold, wrap: "wrap", children: p.text })) }),
72 ),
73 });
74 });
75}
76
77async function record($, change, agentId) {
78 const { value: requests = [] } = await $.state.get(REQUESTS);
79 const now = await $.clock.now();
80 let by = "me";
81 if (agentId) {
82 const agent = (await $.agent.list()).find((a) => a.id === agentId);
83 by = agent ? `subagent "${agent.name ?? agent.description}"` : "a subagent";
84 }
85 const list = requests.length ? requests : [{ ask: "(before your first message)", at: now, files: [], checks: [], commits: [] }];
86 await $.state.set(REQUESTS, [...list.slice(0, -1), change(list[list.length - 1], now, by)]);
87}
88
89async function build($) {
90 const { value: requests = [] } = await $.state.get(REQUESTS);
91 const touched = requests.filter((r) => r.files.length || r.checks.length || r.commits.length);
92 if (!touched.length) return [[{ text: "No changes yet this session.", dim: true }]];
93
94 const allFiles = [...new Set(touched.flatMap((r) => r.files.map((f) => f.path)))];
95 const dirty = await uncommitted($, allFiles);
96 const lastWrite = new Map();
97 for (const r of touched) for (const f of r.files) lastWrite.set(f.path, Math.max(lastWrite.get(f.path) ?? 0, f.at));
98 const foreign = new Set();
99 for (const [path, at] of lastWrite) {
100 try {
101 if ((await $.fs.stat(path)).mtimeMs > at + FOREIGN_MS) foreign.add(path);
102 } catch {}
103 }
104
105 const rows = [];
106 const hidden = touched.length - SHOWN;
107 if (hidden > 0) rows.push([{ text: `(${hidden} earlier requests not shown)`, dim: true }]);
108 for (const r of touched.slice(-SHOWN)) {
109 rows.push([{ text: `▸ ${r.ask}`, bold: true }]);
110
111 if (r.files.length) {
112 const mine = r.files.filter((f) => f.by === "me");
113 const theirs = r.files.filter((f) => f.by !== "me");
114 rows.push([{ text: " changed: ", dim: true }, { text: mine.map((f) => short(f.path)).join(", ") || "-" }]);
115 for (const f of theirs) rows.push([{ text: ` by ${f.by}: `, dim: true }, { text: short(f.path) }]);
116 const knobs = r.files.filter((f) => /\/knobs\/[^/]+\.ts$/.test(f.path));
117 if (knobs.length) rows.push([{ text: ` knob registry edited: ${knobs.map((f) => short(f.path)).join(", ")}`, color: "cyan" }]);
118
119 const lastEdit = Math.max(...r.files.map((f) => f.at));
120 const after = r.checks.filter((c) => c.at >= lastEdit);
121 const before = r.checks.filter((c) => c.at < lastEdit);
122 if (!after.length) {
123 rows.push([
124 { text: " NOT CHECKED since the last edit", color: "yellow", bold: true },
125 ...(before.length ? [{ text: ` (earlier: ${before.map((c) => `${c.what} ${c.ok ? "✓" : "✗"}`).join(" ")})`, dim: true }] : []),
126 ]);
127 } else rows.push([{ text: " checked: ", dim: true }, ...after.map((c) => ({ text: `${c.what} ${c.ok ? "✓" : "✗"} `, color: c.ok ? "green" : "red" }))]);
128 } else if (r.checks.length) {
129 rows.push([{ text: " checked: ", dim: true }, ...r.checks.map((c) => ({ text: `${c.what} ${c.ok ? "✓" : "✗"} `, color: c.ok ? "green" : "red" }))]);
130 }
131
132 for (const c of r.commits) {
133 const partial = /\bpartial\b/i.test(c.subject);
134 rows.push([{ text: ` commit ${c.hash} `, dim: true }, { text: clip(c.subject, 70) }, ...(partial ? [{ text: " PARTIAL", color: "yellow" }] : [])]);
135 }
136
137 const open = r.files.filter((f) => dirty.has(f.path)).map((f) => short(f.path));
138 if (open.length) rows.push([{ text: ` not committed: ${[...new Set(open)].join(", ")}`, color: "yellow" }]);
139 const clash = r.files.filter((f) => foreign.has(f.path)).map((f) => short(f.path));
140 if (clash.length) rows.push([{ text: ` ⚠ changed since by someone else: ${[...new Set(clash)].join(", ")}`, color: "red" }]);
141 }
142 return rows;
143}
144
145async function uncommitted($, paths) {
146 if (!paths.length) return new Set();
147 try {
148 const { stdout } = await $.process.run(["git", "status", "--porcelain", "--no-renames", "-z", "--", ...paths]);
149 const top = (await $.process.run(["git", "rev-parse", "--show-toplevel"])).stdout.trim();
150 return new Set(stdout.split("\0").filter(Boolean).map((l) => `${top}/${l.slice(3)}`));
151 } catch {
152 return new Set();
153 }
154}
155
156function short(path) {
157 const parts = path.split("/");
158 return parts.length > 2 ? parts.slice(-2).join("/") : path;
159}
160
161function clip(s, n) {
162 return s.length > n ? `${s.slice(0, n - 1)}…` : s;
163}
164types/index.d.ts 17 lines1export type LedgerFile = { path: string; by: string; at: number };
2export type LedgerCheck = { what: string; ok: boolean; at: number };
3export type LedgerCommit = { hash: string; subject: string; at: number };
4export type LedgerRequest = {
5 ask: string;
6 at: number;
7 files: LedgerFile[];
8 checks: LedgerCheck[];
9 commits: LedgerCommit[];
10};
11
12declare module "claude-code" {
13 interface PluginState {
14 "session-ledger": { requests: LedgerRequest[] };
15 }
16}
17