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/. Install says where to get the package, and where each package manager puts this directory. Point Claude Code at it:

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

A record goes to the file of the stream its hook event came from. An event inside a subagent carries agent_id, and the file takes that name. No event on the main stream carries agent_id, so an event without one goes to main.jsonl. The same absence decides who records an edit: the plugin records one only when the event carries agent_id. See What was verified.

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. Hook input carries neither a message id nor a request id. So a record names its tool call and never the provider call that made it, and the view joins it to its step by the tool-use id alone. See Workspace changes.

Two hooks can run at the same time. So each record is written whole, in one write to a file opened for appending, and synced to disk before the hook returns. asz lands a file only up to its last complete newline, and a line still being written waits for the next pass. Appending with cat >> to one shared file from concurrent subagents was seen to corrupt lines above about 64 KB. The sample it was seen on is unavailable.

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.

Each file it lands names the dialect asz-changes/1 in its header, not claude-code/1. The plugin writes its records in the model’s own words, not in Claude Code’s shape. Records of a different shape get a different dialect, even when the same runtime produced them, as the adapter contract says.

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. Subagent edits must be inside one of these roots and pass the same exclusion and symbolic link rules as a scan. Their record names the root containing the file and the policy in force. 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. This applies to subagent edits too, including patches supplied by the runtime. The cap applies to both the original and resulting content, and to the patch itself.

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-v2, with the restrictions below:

cd grep cat ls head tail wc cut tr diff cmp echo printf pwd which type stat
du df date printenv jq basename dirname realpath readlink test [ true false sleep
ps uname hostname whoami id nl column comm od hexdump strings sha256sum shasum md5sum
seq expr wait read
env    with no arguments
sort   without -o, --output or --compress-program
tree   without -o or --output
rg     without --pre or --hostname-bin
sed    only -n followed by a print command, such as '10,20p' or '/start/,/end/p', then file names
find   without -delete, -exec, -execdir, -ok, -okdir, -fprint, -fprint0, -fprintf or -fls
awk    one inline program without system, redirection, pipes or extensions; no option arguments
git    status log diff show rev-parse describe blame ls-files ls-tree cat-file grep shortlog
       for-each-ref check-ignore version merge-base count-objects name-rev, and stash list,
       stash show, worktree list, worktree prune; branch, remote, tag, config, symbolic-ref
       and reflog only in recognized query forms
go     version, or env without -w or -u

An env invocation with arguments and every xargs invocation are scanned: they can run another program or supply writing options. Git commands with explicit output files, external programs, filters, text conversion or -c settings are scanned too. git add, git fetch and git commit are scanned because filters or hooks can write outside .git/. Go package loading can update module files, so go list and go doc are scanned. Unknown forms, including compound for and case syntax, also scan.

A variable assignment at the start of a segment scans too, as in GIT_EXTERNAL_DIFF=./tool git diff, because a variable such as GIT_EXTERNAL_DIFF, GIT_CONFIG_* or LD_PRELOAD can run another program. A bare assignment scans as well: it changes a variable the shell may already export. Only these variables leave a segment read-only, since they change how output is formatted: LANG, LANGUAGE, LC_ALL, LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_NUMERIC, LC_TIME, TZ, TERM, COLUMNS, LINES, NO_COLOR, CLICOLOR, CLICOLOR_FORCE and FORCE_COLOR.

The previous readonly-v1 classified 69.3% of the corpus above as read-only. The skip rate of readonly-v2 has not been measured. The fixture plugins/claude-code/internal/readonly/testdata/commands.txt holds the same 93 real commands, with their expectations updated for this policy, and the classifier’s test reads it. The new policy has its own name so older records keep the meaning of the classifier they ran under.

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 for retention.ttl is 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.

Claude Code runs a hook synchronously, so the tool call waits until the hook returns. That is why a scan has a cap. A scan stops at scan_timeout and the record says so, with coverage: partial. The cap is for one scan, and a hook scans each root in turn. The default, 30 seconds, is half the 60 seconds hooks/hooks.json gives each tool event. A hook still running at its timeout is killed and leaves no record for its call, so a scan_timeout near 60 seconds loses records instead of marking them partial. The scans of one root take turns under a lock, and a hook waiting for that lock is bounded only by its own timeout. The session’s start and end run no scan, and get 30 seconds.

On 5 live headless sessions with a hook on every event, a hook invocation took about 5.6 ms. Those hooks ran no scan. How long a scan takes has not been measured.

A hook that fails, for any reason, exits 0 and writes to ${CLAUDE_PLUGIN_DATA}/log/plugin.log. It never stops the tool.

The hook command

hooks/hooks.json gives every hook the binary as its command and hook as its one argument:

{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/bin/asz-claude-plugin", "args": ["hook"], "timeout": 60}

The Claude Code hooks reference says that when a hook has args, Claude Code starts command directly, with args as its arguments and no shell, and that no shell splits the command into words on any platform. It also says the plugin’s directory replaces ${CLAUDE_PLUGIN_ROOT} as plain text that no shell reads, so a space, an apostrophe or a $ in that path passes through unchanged. Claude Code 2.1.260 ran the hooks in this form on macOS. It has not run them on Windows.

The plugin used to give one command line and no args: "${CLAUDE_PLUGIN_ROOT}/bin/asz-claude-plugin" hook. The hooks reference says Claude Code runs such a line in a shell, and that on Windows without Git Bash the shell is PowerShell. PowerShell’s own documentation, in about_Operators, says a quoted path is shown as a string, not run, unless the call operator & comes first. The Claude Code 2.1.260 program, read on macOS, puts the line between a short preamble and a suffix that sets the exit status, and adds no &. On macOS, PowerShell 7.5.3 refused the line with Unexpected token 'hook' in expression or statement, both alone and inside that preamble and suffix, and the binary did not start. With & in front, it started. Neither Claude Code on Windows nor Windows PowerShell 5.1 was tried.

A Claude Code that does not know args may drop it and run the binary with no argument. The binary would then print its usage text and exit 2, and exit status 2 from a PreToolUse hook blocks the tool. So with no argument, the binary runs as hook when standard input is a pipe, a socket, a file, or anything else that is not a terminal or another character device. Claude Code 2.1.245, 2.1.259 and 2.1.260 know args. The first version that knows it is unavailable.

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. It is the id in the subagent transcript’s file name.
  • No event on the main stream carries agent_id.
  • A failed shell command fires PostToolUseFailure, with the exit code in error.
  • The Edit response carries the patch and the original content.
  • So does a Write over an existing file. Its patch ends with the git marker line for a missing final newline.
  • The NotebookEdit response names its file as notebook_path. It carries the whole file before and after and no patch, so the plugin computes the hunks from the two.
  • CLAUDE_PLUGIN_DATA exists before the first hook runs.
  • A hook past its timeout is killed, and the tool proceeds.

A second sample, 5 live headless sessions with a hook on every event, agrees. agent_id is on every event inside a subagent and on no event of the main stream. No event in that sample carries a message id or a request id.

The plugin itself was run inside Claude Code on macOS with a shell command, an edit and a subagent. asz collected and showed the result.

On 2026-09-11, Claude Code 2.1.260 on macOS ran the hooks with args, in a session whose one shell command wrote a file. The command ran, and the plugin’s record named the file as created. The session had its own configuration directory. Its model was a local program that answers as the Messages API does, so no request left the machine. The same session with args removed from every hook ran the binary with no argument. A binary without the no-argument rule above printed its usage text and exited 2, and Claude Code blocked the shell command. The current binary ran as hook and recorded the change. In both forms, Claude Code handed each hook its event on a socket.

On Windows the plugin has run only outside Claude Code. CI’s packages job runs tools/package-smoke.sh on each binary package, on a runner of the package’s own platform. On 2026-09-11, the CI of pull request #6 ran it on windows-latest, x86-64, and on windows-11-arm, ARM 64. Both jobs passed, in 30 and 33 seconds. The script unpacks the zip with Expand-Archive. It runs the packaged plugin with a SessionStart, a PreToolUse, a PostToolUse and a SessionEnd event on standard input, and writes a file between the two tool events. Those runs checked only that some file in the plugin’s data directory named it. The plugin’s scan writes its own files, which name it too, before the plugin writes the record. So those runs do not show that the plugin wrote its record on Windows. The script now requires the record in output/<session-id>/main.jsonl to name the file as created, and CI’s unit tests now run the plugin’s own tests on each system. Neither has run on Windows yet.

Claude Code itself has not run the hooks on Windows. The hooks reference says a hook with args uses no shell and ignores shell, so whether Git Bash is installed should not matter. That has not been tried. It is also unknown whether the binary starts at all. The Windows packages hold bin\asz-claude-plugin.exe, and hooks/hooks.json names bin/asz-claude-plugin. The hooks reference says that on Windows, command must resolve to a real executable, such as a .exe. It does not say whether a path without .exe resolves to one.

To find out, on Windows x86-64 or ARM 64:

  1. Unpack the package for the machine, and start Claude Code with claude --plugin-dir <package>\claude-code-plugin.
  2. Ask for one shell command that writes a file.
  3. Look in the plugin’s data directory. For a plugin loaded with --plugin-dir, Claude Code 2.1.260 on macOS put it at plugins/data/asz-changes-inline under its configuration directory. A record in output\<session-id>\main.jsonl there that names the file means the hooks work. If log\plugin.log does not exist, the binary never started. The likely cause is that the name did not resolve, and then the Windows packages need a hooks/hooks.json of their own that names asz-claude-plugin.exe.