Buries every file Claude deletes under an ASCII tombstone; /seance brings it back

A Claude Code mod that buries every file Claude deletes under an ASCII tombstone, and lets you raise it again from git.
rm in a Bash call, or a Write or Edit that empties a file, buries the file with its line count, age and an epitaph.⚰ N buried this session shows above the prompt./graveyard opens a pane of tombstones./seance <file> restores a buried file from its last git commit. A file that was never committed stays gone.Files named through globs (rm *.log) or variables (rm $f) are not buried, since what they name is unknown until the shell runs.
At the prompt of a Claude Code terminal session:
/plugin install graveyard --marketplace enerrio/graveyard
Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).
From a shell:
claude plugin install graveyard --marketplace enerrio/graveyard
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with enerrio/graveyard, then Add plugin.
hooks/register.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Grave } from '../types'
5import { epitaphFor } from './epitaph'
6import { rmTargets } from './rm'
7
8const PANE = 'graveyard'
9const graves = atom({ plugin: 'graveyard', key: 'graves' } as const, [])
10
11const STONE_WIDTH = 22
12const MAX_RECURSIVE = 200
13
14type $ = EngineInterface
15
16const run = async ($: $, argv: string[], cwd?: string) => {
17 const ran = await $.process
18 .run(argv, { cwd, timeoutMs: 5000 })
19 .catch(() => undefined)
20 return ran?.exitCode === 0 ? ran.stdout : undefined
21}
22
23const join = (dir: string, path: string) =>
24 path.startsWith('/') ? path : `${dir.replace(/\/$/, '')}/${path}`
25
26const dirOf = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'
27const baseOf = (path: string) => path.slice(path.lastIndexOf('/') + 1)
28
29// Everything known about a file, read before it dies; undefined when it is
30// not a regular file. Nothing is copied: a file git knows can be raised
31// from its last commit, any other stays gone.
32const exhume = async ($: $, path: string): Promise<Grave | undefined> => {
33 const stat = await $.fs.stat(path).catch(() => undefined)
34 if (stat?.kind !== 'file') return undefined
35
36 const id = `${await $.clock.now()}-${Math.random().toString(36).slice(2, 8)}`
37 const name = baseOf(path)
38 const wc = await run($, ['wc', '-l', path])
39 const cwd = dirOf(path)
40 const tracked = (await run($, ['git', 'ls-files', '--full-name', '--', name], cwd))?.trim()
41 const repo = tracked ? (await run($, ['git', 'rev-parse', '--show-toplevel'], cwd))?.trim() : undefined
42 const log = tracked ? await run($, ['git', 'log', '--format=%H %cI', '--', name], cwd) : undefined
43 const commits = (log ?? '').split('\n').filter(Boolean).map(line => line.split(' '))
44 const [commit, lastCommit = null] = commits[0] ?? []
45 const firstCommit = commits[commits.length - 1]?.[1]
46 const git = tracked && repo && commit ? { repo, path: tracked, commit } : null
47 const bornYear = firstCommit
48 ? Number(firstCommit.slice(0, 4))
49 : stat.mtimeMs > 0
50 ? new Date(stat.mtimeMs).getFullYear()
51 : null
52
53 return {
54 id,
55 path,
56 name,
57 lines: Number((wc ?? '').trim().split(/\s+/)[0]) || 0,
58 bytes: stat.size,
59 bornYear,
60 lastCommit,
61 diedAt: await $.clock.now(),
62 epitaph: epitaphFor(name),
63 git,
64 isRisen: false,
65 }
66}
67
68// A tool call that will delete files: bury them first, let it run untouched,
69// then un-bury any that survived it.
70const witness = async (
71 $: $,
72 paths: string[],
73 stillAlive: (path: string) => Promise<boolean>,
74) => {
75 const buried: Grave[] = []
76 for (const path of paths) {
77 const grave = await exhume($, path).catch(() => undefined)
78 if (grave) buried.push(grave)
79 }
80 return async () => {
81 const dead: Grave[] = []
82 for (const grave of buried) {
83 if (!(await stillAlive(grave.path))) dead.push(grave)
84 }
85 if (dead.length > 0) await update($, graves, list => [...list, ...dead])
86 }
87}
88
89const expandRecursive = async ($: $, path: string) => {
90 const stat = await $.fs.stat(path).catch(() => undefined)
91 if (stat?.kind === 'file') return [path]
92 if (stat?.kind !== 'dir') return []
93 const found = await run($, ['find', path, '-type', 'f'])
94 return (found ?? '').split('\n').filter(Boolean).slice(0, MAX_RECURSIVE)
95}
96
97const pad = (text: string, width: number) => {
98 const cut = text.length > width ? `${text.slice(0, width - 1)}~` : text
99 const left = Math.floor((width - cut.length) / 2)
100 return ' '.repeat(left) + cut + ' '.repeat(width - cut.length - left)
101}
102
103const wrap = (text: string, width: number, rows: number) => {
104 const out: string[] = []
105 let line = ''
106 for (const word of text.split(' ')) {
107 if (line && line.length + 1 + word.length > width) {
108 out.push(line)
109 line = word
110 } else {
111 line = line ? `${line} ${word}` : word
112 }
113 }
114 if (line) out.push(line)
115 if (out.length > rows) {
116 out.length = rows
117 out[rows - 1] = `${(out[rows - 1] ?? '').slice(0, width - 3)}...`
118 }
119 while (out.length < rows) out.push('')
120 return out
121}
122
123const tombstone = (grave: Grave) => {
124 const inner = STONE_WIDTH - 4
125 const died = new Date(grave.diedAt).getFullYear()
126 const years = `${grave.bornYear ?? '?'} - ${died}`
127 const body = [
128 grave.isRisen ? 'RISEN' : 'here lies',
129 grave.name,
130 `${grave.lines} lines`,
131 years,
132 '',
133 ...wrap(grave.epitaph, inner, 3),
134 ]
135 return [
136 ` .${'-'.repeat(STONE_WIDTH - 10)}. `,
137 ` .'${' '.repeat(STONE_WIDTH - 8)}'. `,
138 ` / ${pad(grave.isRisen ? ' ' : '+', inner)} \\ `,
139 ...body.map(row => ` | ${pad(row, inner)} | `),
140 `_|${'_'.repeat(inner + 2)}|_`,
141 ]
142}
143
144export const register: Register = on => {
145 on('session.start', async ($, e, next) => {
146 await $.command.register({
147 name: 'graveyard',
148 description: 'Visit the tombstones of every file deleted this session',
149 })
150 await $.command.register({
151 name: 'seance',
152 description: 'Raise a deleted file from its last git commit',
153 argumentHint: '<file>',
154 })
155 return next(e)
156 })
157
158 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
159 const root = await $.session.root()
160 const home = (await $.env.get('HOME')) ?? ''
161 const paths: string[] = []
162 for (const target of rmTargets(e.command)) {
163 const cwd = target.cwd ? join(root, target.cwd) : root
164 const path = join(cwd, target.path.replace(/^~(?=\/|$)/, home))
165 paths.push(...(target.isRecursive ? await expandRecursive($, path) : [path]))
166 }
167 if (paths.length === 0) return next(e)
168
169 const settle = await witness($, paths, path => $.fs.exists(path))
170 const ran = await next(e)
171 await settle()
172 return ran
173 }).catch(($, e, next) => next(e))
174
175 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
176 const before = await $.fs.read(e.file_path).catch(() => '')
177 if (e.content.trim() !== '' || before.trim() === '') return next(e)
178
179 const settle = await witness($, [e.file_path], async path =>
180 ((await $.fs.read(path).catch(() => '')) as string).trim() !== '',
181 )
182 const ran = await next(e)
183 await settle()
184 return ran
185 }).catch(($, e, next) => next(e))
186
187 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
188 const before = await $.fs.read(e.file_path).catch(() => '')
189 const after = e.replace_all
190 ? before.split(e.old_string).join(e.new_string)
191 : before.replace(e.old_string, () => e.new_string)
192 if (before.trim() === '' || after.trim() !== '') return next(e)
193
194 const settle = await witness($, [e.file_path], async path =>
195 ((await $.fs.read(path).catch(() => '')) as string).trim() !== '',
196 )
197 const ran = await next(e)
198 await settle()
199 return ran
200 }).catch(($, e, next) => next(e))
201
202 on('command.run', { command: 'graveyard' }, async $ => {
203 await $.ui.open({ id: PANE, title: 'Graveyard' })
204 const count = (await read($, graves)).length
205 return { text: count === 0 ? 'The graveyard is empty. For now.' : 'The gates creak open.' }
206 })
207
208 on('command.run', { command: 'seance' }, async ($, e) => {
209 const wanted = e.args.trim()
210 if (wanted === '') return { text: 'Usage: /seance <file>' }
211
212 const root = await $.session.root()
213 const full = join(root, wanted)
214 const list = await read($, graves)
215 const grave = [...list]
216 .reverse()
217 .find(one => !one.isRisen && (one.path === full || one.path === wanted || one.name === wanted))
218 if (!grave) return { text: `No spirit named ${wanted} answers.` }
219
220 const { git } = grave
221 if (!git) {
222 return { text: `${grave.name} was never committed. It is gone for good.` }
223 }
224 const living = await $.fs.read(grave.path).catch(() => '')
225 if (living.trim() !== '') {
226 return { text: `Something already haunts ${grave.path}; the seance is called off.` }
227 }
228
229 const restored = await run(
230 $,
231 ['git', 'restore', `--source=${git.commit}`, '--worktree', '--', git.path],
232 git.repo,
233 )
234 if (restored === undefined) return { text: `The spirit of ${grave.name} would not cross over.` }
235
236 await update($, graves, all =>
237 all.map(one => (one.id === grave.id ? { ...one, isRisen: true } : one)),
238 )
239 $.ui.toast(`the spirit of ${grave.name} returns`)
240 return { text: `Restored ${grave.path} as of commit ${git.commit.slice(0, 7)}` }
241 })
242
243 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
244 const { Box, Text } = $.ui.resolve(e)
245 const list = await read($, graves)
246 if (list.length === 0) {
247 return (
248 <Box flexDirection="column">
249 <Text dimColor>No one has died yet. The ground is fresh.</Text>
250 </Box>
251 )
252 }
253
254 const columns = e.viewport?.columns ?? 80
255 const perRow = Math.max(1, Math.floor((columns - 2) / (STONE_WIDTH + 1)))
256 const rows: Grave[][] = []
257 for (let i = 0; i < list.length; i += perRow) rows.push(list.slice(i, i + perRow))
258
259 return (
260 <Box flexDirection="column">
261 <Text dimColor>
262 {list.length} buried, {list.filter(one => one.isRisen).length} risen. /seance {'<file>'} to raise one.
263 </Text>
264 {rows.map((row, r) => {
265 const stones = row.map(tombstone)
266 return (
267 <Box key={`row-${r}`} flexDirection="column" marginTop={1}>
268 {(stones[0] ?? []).map((_, line) => (
269 <Text key={`l-${r}-${line}`} wrap="truncate-end">
270 {stones.map(stone => stone[line]).join(' ')}
271 </Text>
272 ))}
273 <Text dimColor wrap="truncate-end">
274 {'~'.repeat(Math.min(columns - 2, row.length * (STONE_WIDTH + 1)))}
275 </Text>
276 </Box>
277 )
278 })}
279 </Box>
280 )
281 })
282
283 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284 const count = (await read($, graves)).length
285 if (count === 0 || e.props.hasSurvey) return next(e)
286
287 const { Box, Text } = $.ui.resolve(e)
288 return (
289 <Box>
290 <Text dimColor>⚰ {count} buried this session</Text>
291 </Box>
292 )
293 })
294}
295hooks/epitaph.ts 62 lines1const BY_NAME: [RegExp, string[]][] = [
2 [/(^|[._-])(test|spec)s?([._-]|$)/i, ['It failed so others could pass.', 'Asserted itself to the very end.']],
3 [/^\.env/, ['It kept our secrets. Now it keeps its own.']],
4 [/^readme/i, ['Everyone meant to read it someday.']],
5 [/^(todo|notes?)\b/i, ['Its work was never done.']],
6 [/lock|\.lock$/i, ['Pinned in life, unpinned in death.']],
7 [/(^|[._-])(tmp|temp|scratch)/i, ['It always knew it was temporary.']],
8 [/(^|[._-])(old|bak|backup|copy|orig)([._-]|$)/i, ['A backup of a backup, finally at rest.']],
9 [/(^|[._-])(util|utils|helpers?)([._-]|$)/i, ['It helped everyone but itself.']],
10 [/(^|[._-])(config|settings|conf)([._-]|$)/i, ['Configured to the end. Never quite right.']],
11 [/(^|[._-])(main|index|app)\./i, ['Once the entry point. Now the exit.']],
12 [/\.log$/, ['It wrote down everything. No one read it.']],
13 [/^makefile$/i, ['Made all, cleaned at last.']],
14]
15
16const BY_EXTENSION: Record<string, string[]> = {
17 py: ['Indented faithfully, dedented forever.', 'It raised no exception at the end.'],
18 js: ['undefined is not a function. Neither is it, now.'],
19 ts: ['Strictly typed, loosely remembered.'],
20 tsx: ['It rendered its last.'],
21 md: ['Its words outlived its usefulness.'],
22 json: ['Trailing commas could not save it.'],
23 yaml: ['Two spaces too far.'],
24 yml: ['Two spaces too far.'],
25 sh: ['exit 0'],
26 css: ['It had style. It had !important.'],
27 html: ['Closed its last tag.'],
28 sql: ['DROP TABLE, then DROP SELF.'],
29 rs: ['The borrow is returned.'],
30 go: ['It went.'],
31 txt: ['Plain in life, plain in death.'],
32 csv: ['Comma separated, now separated for good.'],
33}
34
35const GENERIC = [
36 'Gone to /dev/null.',
37 'Unlinked, but not forgotten.',
38 'rm -f, rest in peace.',
39 'It compiled once. That was enough.',
40 'Deleted with love. Probably.',
41 'No commit could hold it.',
42 'Its last diff was all red.',
43]
44
45const hash = (text: string) => {
46 let h = 2166136261
47 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
48 return h >>> 0
49}
50
51const pick = (pool: string[], name: string) => pool[hash(name) % pool.length] ?? 'Rest in peace.'
52
53// A short epitaph for a file, chosen from its name so the same name always
54// gets the same words.
55export const epitaphFor = (name: string) => {
56 const byName = BY_NAME.find(([pattern]) => pattern.test(name))
57 if (byName) return pick(byName[1], name)
58 const dot = name.lastIndexOf('.')
59 const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
60 return pick(BY_EXTENSION[extension] ?? GENERIC, name)
61}
62hooks/rm.ts 103 lines1export type RmTarget = { path: string; cwd?: string; isRecursive: boolean }
2
3const OPERATORS = new Set([';', '&&', '||', '|', '&', '\n'])
4
5// Splits a shell command into words and operators, honouring quotes and
6// backslashes; a word that held a glob outside quotes keeps `isGlob`.
7const tokenize = (command: string) => {
8 const tokens: { text: string; isOperator: boolean; isGlob: boolean }[] = []
9 let word = ''
10 let isWord = false
11 let isGlob = false
12 const flush = () => {
13 if (isWord) tokens.push({ text: word, isOperator: false, isGlob })
14 word = ''
15 isWord = false
16 isGlob = false
17 }
18 for (let i = 0; i < command.length; i++) {
19 const c = command[i] ?? ''
20 const pair = command.slice(i, i + 2)
21 if (c === "'") {
22 const end = command.indexOf("'", i + 1)
23 word += command.slice(i + 1, end < 0 ? undefined : end)
24 isWord = true
25 i = end < 0 ? command.length : end
26 } else if (c === '"') {
27 let j = i + 1
28 while (j < command.length && command[j] !== '"') {
29 if (command[j] === '\\' && j + 1 < command.length) j++
30 word += command[j] ?? ''
31 j++
32 }
33 isWord = true
34 i = j
35 } else if (c === '\\' && i + 1 < command.length) {
36 word += command[++i] ?? ''
37 isWord = true
38 } else if (pair === '&&' || pair === '||') {
39 flush()
40 tokens.push({ text: pair, isOperator: true, isGlob: false })
41 i++
42 } else if (OPERATORS.has(c)) {
43 flush()
44 tokens.push({ text: c, isOperator: true, isGlob: false })
45 } else if (c === ' ' || c === '\t') {
46 flush()
47 } else {
48 if (c === '*' || c === '?' || c === '[') isGlob = true
49 word += c
50 isWord = true
51 }
52 }
53 flush()
54 return tokens
55}
56
57// The files and folders each `rm` in a command names, with the folder a
58// preceding `cd` in the same command moved to. Globs and `$` expansions are
59// skipped: what they name is unknown until the shell runs.
60export const rmTargets = (command: string): RmTarget[] => {
61 const targets: RmTarget[] = []
62 let cwd: string | undefined
63 let words: { text: string; isGlob: boolean }[] = []
64
65 const segment = () => {
66 let i = 0
67 while (/^\w+=/.test(words[i]?.text ?? '')) i++
68 if (words[i]?.text === 'sudo' || words[i]?.text === 'command') i++
69 const head = words[i]?.text ?? ''
70 if (head === 'cd' && words[i + 1]) {
71 const to = words[i + 1]?.text ?? '.'
72 cwd = to.startsWith('/') || cwd === undefined ? to : `${cwd}/${to}`
73 }
74 if (head === 'rm' || head.endsWith('/rm')) {
75 let isRecursive = false
76 let isOptions = true
77 const paths: string[] = []
78 for (const word of words.slice(i + 1)) {
79 if (isOptions && word.text === '--') {
80 isOptions = false
81 } else if (isOptions && /^-[a-zA-Z]+$/.test(word.text)) {
82 if (/[rR]/.test(word.text)) isRecursive = true
83 } else if (isOptions && /^--recursive$/.test(word.text)) {
84 isRecursive = true
85 } else if (isOptions && word.text.startsWith('--')) {
86 // another long option
87 } else if (!word.isGlob && !word.text.includes('$') && word.text !== '') {
88 paths.push(word.text)
89 }
90 }
91 for (const path of paths) targets.push({ path, cwd, isRecursive })
92 }
93 words = []
94 }
95
96 for (const token of tokenize(command)) {
97 if (token.isOperator) segment()
98 else words.push(token)
99 }
100 segment()
101 return targets
102}
103types/index.d.ts 24 lines1// Where git last held a dead file: the repository, the file's path in it,
2// and the last commit that touched it. Null for a file git never tracked.
3export type GraveGit = { repo: string; path: string; commit: string }
4
5export type Grave = {
6 id: string
7 path: string
8 name: string
9 lines: number
10 bytes: number
11 bornYear: number | null
12 lastCommit: string | null
13 diedAt: number
14 epitaph: string
15 git: GraveGit | null
16 isRisen: boolean
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 graveyard: { graves: Grave[] }
22 }
23}
24