Claude Code Plugin
The asz Claude Code plugin records which files each tool call changed. It runs inside Claude Code as a set of hooks, needs nothing from asz, and leaves one JSON line per observed call in its own data directory. asz collects those lines the way it collects Claude Code’s own transcripts, and the conversation page shows each change beside the step that made it.
The plugin is named asz-changes. Its code is under plugins/claude-code/ in the repository,
and its binary, asz-claude-plugin, ships beside asz in every binary package.
What is recorded, and by whom
Claude Code records some changes itself. Every successful Edit and Write on the main stream
carries the runtime’s own patch in the transcript, measured on 2,221 of 2,221 such results in a
52-session corpus. asz reads those without the plugin: the Claude Code adapter copies each patch
into a change record beside the raw result when it lands the transcript.
The plugin records what Claude Code does not:
| Tool | Main stream | Inside a subagent | How |
|---|---|---|---|
Bash, PowerShell, Monitor |
plugin | plugin | a scan of the workspace before and after the call |
Edit, Write, NotebookEdit |
Claude Code | plugin | the patch the hook’s own response carries; a subagent’s transcript holds none |
Read, Grep, Glob and the other read-only tools |
nobody | nobody | they change nothing |
Agent |
nobody | the child’s own hooks observe the child’s calls |
A shell command the plugin classifies as read-only skips its scans and still leaves a record, so the absence of changes reads as “not observed” and never as “nothing changed”. A change made between two observed calls by something no hook covered, a person, an editor, an unhooked tool, is recorded as well, unattributed, when the next scan finds it. An edit the runtime recorded is not one of those: the plugin’s hook on the editing tools brings its manifest up to date with the file the tool wrote, on both streams, so the next scan does not find that edit as nobody’s, and a window open at the same time sees it as shared with the edit and names it.
Install
The binary package for your platform holds claude-code-plugin/: the plugin’s manifest, its
hooks, and its binary under bin/. Point Claude Code at that directory:
claude --plugin-dir /path/to/claude-code-plugin
or add it to your Claude Code settings the way the Claude Code documentation describes for a
local plugin. Claude Code sets CLAUDE_PLUGIN_DATA for the plugin’s hooks and creates the
directory it names, ~/.claude/plugins/data/asz-changes-<marketplace>/, which is where the
plugin keeps its state and its output. Nothing else is configured, and asz need not be installed.
To build the plugin from a checkout, make build puts the binary at
plugins/claude-code/bin/asz-claude-plugin, where hooks/hooks.json expects it, so
claude --plugin-dir plugins/claude-code runs the checkout’s plugin.
asz-claude-plugin status, with CLAUDE_PLUGIN_DATA set, prints the settings in force and the
exclusion rules they expand to.
Output
One file per stream of a session, one line per record, appended and never rewritten:
${CLAUDE_PLUGIN_DATA}/output/<session-id>/main.jsonl
${CLAUDE_PLUGIN_DATA}/output/<session-id>/<agent-id>.jsonl
Each line is a changes/1 record: the tool-use id it belongs to as its id, captured_by: asz-plugin, the session, the stream, the time, how it was observed, the root, the policy it ran
under, and for each changed file its path, the operation, size and hash before and after, and the
hunks as a unified diff with line numbers. pkg/changes in the repository defines the shape.
asz’s claude-code-changes adapter, on by default, finds these files beside Claude Code’s own,
tails them, and lands each line as a record of kind changes under the stream the tool ran in.
See Configuration.
Settings
Optional, in ${CLAUDE_PLUGIN_DATA}/settings.yaml. Every value has a default, and the plugin runs
with no file at all.
roots: [] # empty: the session's project directory
exclude:
defaults: standard-v1
add: []
remove: []
read_only:
enabled: true
tools:
scope: [Bash, PowerShell, Monitor]
retention:
idle: 30m
ttl: 720h
scan_timeout: 30s
size_cap: 1048576
roots are the directories observed; empty means the project directory Claude Code hands every
hook. tools.scope names the tools observed with a scan; the editing tools need no entry.
Exclusions
Exclusion means no observation: a change under an excluded directory is never seen. Every record names the set it ran under and the rules it expanded to, so history explains itself after the set changes. The set is frozen under its name; a changed set gets a new name.
Two kinds of rule, because the names build tools use are also names source uses. An
unconditional name is excluded wherever it appears; it is never source. A conditional
name is excluded only beside the ecosystem’s own project file, which is how the build tools
themselves decide: build/ beside pom.xml is Maven output, while build/ in a repository with
no project file beside it is kept, because many repositories keep scripts there.
standard-v1:
| Ecosystem | Always excluded | Excluded only beside |
|---|---|---|
| version control | .git .hg .svn |
|
| JVM | target .gradle |
build/ beside pom.xml, build.gradle or build.gradle.kts |
| Go | bin/ dist/ vendor/ beside go.mod |
|
| Node | node_modules .next .nuxt .turbo .parcel-cache .svelte-kit |
dist/ build/ beside package.json |
| Python | __pycache__ .venv venv .tox .nox .mypy_cache .pytest_cache .ruff_cache *.egg-info .eggs |
build/ dist/ beside pyproject.toml or setup.py |
| Rust | target |
|
| C and C++ | cmake-build-* CMakeFiles |
build/ beside CMakeLists.txt |
| .NET | bin/ obj/ beside a *.csproj or *.sln |
|
| Ruby | .bundle vendor/bundle |
tmp/ log/ beside Gemfile |
| PHP | vendor/ beside composer.json |
|
| Swift and Xcode | .build DerivedData Pods Carthage/Build |
|
| Scala | .bloop .metals |
|
| Elixir | _build |
deps/ beside mix.exs |
| Haskell | dist-newstyle .stack-work |
|
| Dart and Flutter | .dart_tool |
build/ beside pubspec.yaml |
| Zig | zig-cache .zig-cache zig-out |
|
| Terraform | .terraform |
|
| Hugo | public/ resources/_gen/ beside hugo.toml, hugo.yaml or config.toml |
|
| Jekyll and MkDocs | _site/ beside _config.yml; site/ beside mkdocs.yml |
|
| coverage | coverage .nyc_output htmlcov |
|
| caches and IDE state | .cache .idea .vs |
Deliberately not in the defaults: packages/, because monorepos keep source there; .claude/,
because skills and settings are content people edit; data/, because the name is generic. An
asz checkout adds its own storage root:
exclude:
defaults: standard-v1
add: ["/data/"]
effective = (defaults − remove) ∪ add. A rule with a leading / is anchored to the root; any
other name matches at any depth. remove names a default to keep observing, for example
**/vendor/. .gitignore is never read. Symbolic links are neither followed nor recorded. A
file over size_cap, or one holding a NUL byte, lands as path and hash only, with the reason.
The read-only skip
Before scanning around a shell command, the plugin classifies the command. The classifier is
small and fails toward scanning. It works on the whole text, never the first word, because most
commands are compound and many read first and then write; it honours quotes, so a | inside a
grep pattern is not a segment boundary, a > inside single quotes is text, and a backtick inside
double quotes still runs a command.
Any of these makes the command scan: an unquoted redirection to anything but /dev/null or
another descriptor, a heredoc, a command substitution, a process substitution. Otherwise the
command is split on unquoted |, ||, &&, ;, & and newlines, and every segment’s first
word must be on this list, readonly-v1:
cd grep rg cat ls head tail wc sort uniq cut tr diff cmp echo printf pwd which type stat file
du df date env printenv jq tree basename dirname realpath readlink test [ true false sleep
ps uname hostname whoami id nl column comm od xxd hexdump strings sha256sum shasum md5sum
seq expr wait read
sed without -i or --in-place
find without -delete, -exec, -execdir, -ok or -fprint
awk without system(
xargs when the program it runs is on this list
git status log diff show branch remote rev-parse describe blame ls-files ls-tree cat-file
tag fetch grep shortlog for-each-ref check-ignore reflog version merge-base config
count-objects name-rev symbolic-ref add, and stash list, stash show, worktree list,
worktree prune
go version env list doc
git add is on the list because it changes only .git/, which is excluded scope. git commit
is not, because a pre-commit hook can rewrite files. A miss is not a loss: the next scan compares
against the last manifest, so a write that slipped through lands as an unattributed change. On
the corpus above, 69.3% of shell commands are read-only under this list. The fixture
plugins/claude-code/internal/readonly/testdata/commands.txt holds 93 real commands with the
outcome each must get, and the classifier’s test reads it.
Retention
Two rules, and neither waits for a collector:
- Snapshot bytes. A scan keeps the bytes of every file it saw, so a later diff has both
sides. When a root has had no window open for
retention.idle, those bytes and the chain of steps are dropped; the manifest of paths and hashes stays, so the next scan still knows what was there and starts fresh. - Output. A file under
output/not written to forretention.ttlis removed, 30 days by default, which matches Claude Code’s own transcript cleanup. Whatever asz has landed is asz’s, under its own retention.
The rules run at the session’s start and end, and before each scan. asz-claude-plugin prune
runs them now.
What a scan is
A scan walks the root under the exclusion rules and hashes what it finds. A file whose size, modification time and inode are as the last scan saw them keeps its hash, unless its modification time falls within the last scan’s own second, when it is hashed again because the clock cannot tell. Every scan appends a step to the root’s chain: which paths changed and from what hash to what. A window is the span of steps between its two scans.
Two tools open at once on one root, two subagents say, see each other. A file changed in a step
only one window spans is that window’s alone; one changed in a step both span is shared, and
each record names the other under overlaps. Nothing claims who wrote a byte: a person or an
editor can write inside any window. The record says which tool windows could have.
A scan stops at scan_timeout and the record says so, with coverage: partial. A hook that
fails, for any reason, exits 0 and writes to ${CLAUDE_PLUGIN_DATA}/log/plugin.log; it never
stops the tool.
What was verified
Each of these was read from a run of Claude Code 2.1.260 with a logging plugin, not from
documentation: hooks/hooks.json with ${CLAUDE_PLUGIN_ROOT} loads and fires; every tool event
carries session_id, tool_use_id, tool_input and cwd; every event inside a subagent
carries agent_id, and it is the id in the subagent transcript’s file name; a failed shell
command fires PostToolUseFailure with the exit code in error; the Edit response carries the
patch and the original content, and so does a Write over an existing file, whose patch ends with
the git marker line for a missing final newline; the NotebookEdit response names its file as
notebook_path with the whole file before and after and no patch, so the plugin diffs the two;
CLAUDE_PLUGIN_DATA exists before the first hook runs; a hook past its timeout is killed and the
tool proceeds. The plugin itself was run inside Claude Code on macOS with a shell command, an
edit and a subagent, and asz collected and showed the result. Windows is a build target; its hook
command line has not been exercised.