Keeps a running list of every file Claude edited or wrote this session: a count in the status line and a /touched pane

Small, tested mods for Claude Code. Each one does one job, asks for the least access it can, and says exactly what it touches.
Mods need Claude Code 2.1.287 or later. They run in the terminal and in the Claude desktop app's Code tab.
Type the line at the prompt of a terminal Claude Code session, answer y to add the marketplace, then pick a scope:
/plugin install secret-guard --marketplace Sajid-Rahman0126/claude-studio-mods
Swap secret-guard for touched-files or kanban-status to install the others.
Blocks Claude's Read, Edit and Write tools, and shell commands, from touching files that usually hold secrets: .env and .env.*, *.pem / *.key / *.p12, SSH private keys, ~/.aws/credentials, .npmrc, .netrc, .pypirc, Docker and GitHub CLI auth files, and *credentials*.json / *service-account*.json. Template files such as .env.example stay allowed. Symlinks are followed, so a harmless-looking link to a key is still blocked. When it blocks something, Claude is told why, and you see a short notice.
Keeps a list of every file Claude edited or wrote in this session. The status line shows a count, and /touched opens a pane with each file and how many times it changed.
Shows an Obsidian Kanban board in the status line, such as Board · Todo 3 · Doing 1. /board opens a pane with every open card. Works with any Markdown file that uses ## Column headings and - [ ] cards.
BOARD.md in the session folder by default. Change it with the Board file option in the plugin's settings. It writes nothing and never uses the network.Mods run with your permissions and are not sandboxed. Read the code before installing any mod, these included; each one is a few dozen lines in mods/<name>/hooks/. To start Claude Code with no mods loaded, run claude --safe-mode.
Each mod has tests. From a mod's folder:
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 73 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { record, shortPath, statusText } from './touches'
5
6const PANE = 'touched-files'
7const files = atom({ plugin: 'touched-files', key: 'files' } as const, [])
8
9async function note($: EngineInterface, path: string, tool: string): Promise<void> {
10 const at = await $.clock.now()
11 const list = await update($, files, current => record(current, path, tool, at))
12 $.ui.status(statusText(list))
13}
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'touched', description: 'List every file Claude changed in this session' })
18 $.ui.status(statusText(await read($, files)))
19 return next(e)
20 })
21
22 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
23 const ran = await next(e)
24 if (ran.deny === undefined && !ran.isError) await note($, e.file_path, 'Edit')
25 return ran
26 })
27
28 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
29 const ran = await next(e)
30 if (ran.deny === undefined && !ran.isError) await note($, e.file_path, 'Write')
31 return ran
32 })
33
34 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
35 const ran = await next(e)
36 if (ran.deny === undefined && !ran.isError) await note($, e.notebook_path, 'NotebookEdit')
37 return ran
38 })
39
40 on('command.run', { command: 'touched' }, async $ => {
41 const list = await read($, files)
42 if (list.length === 0) return { text: 'No files changed yet this session.' }
43 await $.ui.open({ id: PANE, title: 'Changed files' })
44 return { text: statusText(list) ?? '' }
45 })
46
47 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
48 const { Box, Text, Button } = $.ui.resolve(e)
49 const list = await read($, files)
50 const cwd = await $.session.cwd()
51 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
52 return (
53 <Box flexDirection="column">
54 {list.length === 0 && <Text dimColor>No files changed yet this session.</Text>}
55 {list.slice(0, room).map(item => (
56 <Text>
57 {shortPath(item.path, cwd)} <Text dimColor>×{item.edits}</Text>
58 </Text>
59 ))}
60 {list.length > room && <Text dimColor>+{list.length - room} more</Text>}
61 <Button
62 key="clear"
63 label="Clear list"
64 onPress={async () => {
65 await update($, files, () => [])
66 $.ui.status(undefined)
67 }}
68 />
69 </Box>
70 )
71 })
72}
73hooks/touches.ts 23 lines1import type { Touch } from '../types'
2
3// Records one successful edit, keeping the most recently touched file first.
4export function record(list: Touch[], path: string, tool: string, at: number): Touch[] {
5 const existing = list.find(item => item.path === path)
6 const updated: Touch = existing
7 ? { ...existing, edits: existing.edits + 1, lastTool: tool, lastAt: at }
8 : { path, edits: 1, lastTool: tool, lastAt: at }
9 return [updated, ...list.filter(item => item.path !== path)].slice(0, 500)
10}
11
12export function statusText(list: Touch[]): string | undefined {
13 if (list.length === 0) return undefined
14 const edits = list.reduce((sum, item) => sum + item.edits, 0)
15 return `✎ ${list.length} file${list.length === 1 ? '' : 's'} changed (${edits} edit${edits === 1 ? '' : 's'})`
16}
17
18// Shows paths relative to the session folder when they are inside it.
19export function shortPath(path: string, cwd: string): string {
20 const base = cwd.endsWith('/') ? cwd : `${cwd}/`
21 return path.startsWith(base) ? path.slice(base.length) : path
22}
23types/index.d.ts 8 lines1export type Touch = { path: string; edits: number; lastTool: string; lastAt: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'touched-files': { files: Touch[] }
6 }
7}
8