Plugins · CPO
State and git
Remember values between events, read the repository status, snapshot the work tree and diff it.
State
| Scope | Shared by | Cleared |
|---|---|---|
session | every event of the session | at session.end |
turn | events until the next prompt | at turn.prompt |
toolUse | tool.call to tool.result of one tool use (event.data.useId) | when the tool use ends, is denied or blocked |
State is one namespace per scope, shared by all policies. Write it with effects, read it in CEL:
then:
- set: { state.session.writes: "state.session.?writes.orValue(0) + 1" }
- unset: [state.turn.warned]setstores JSON values; all of aset's values are computed before any is assigned.- Writes are buffered during a hook call and committed at its end, under a per-session lock, so parallel hooks never lose updates. A policy that errors has its writes rolled back.
- Files live in
$CPO_STATE_DIR(default<tmp>/ccpp-cpo/state).ccpp cpo tracereads them but never writes.
Example: remember a command at tool.call and use it at its tool.result, in two policy files.
on: [tool.call]
then: [{ set: { state.toolUse.command: "event.data.input.command" } }]on: [tool.result]
let: { before: "state.toolUse.?command.orValue('')" }
when: { cel: "before.startsWith('git')" }
then: [warn]git
git describes the repository containing the project, from one git status run on first read per hook call.
| Field | |
|---|---|
repo | false outside a repository (or without git); every other field is then empty |
root | the work tree root; git paths are relative to it |
head, branch, upstream | commit id, branch (null when detached), upstream (null if none) |
ahead, behind | commits ahead of / behind the upstream |
dirty | any change, untracked files included |
changes | [{ path, from, index, worktree, conflicted }], codes from git status --porcelain=v2 |
staged, unstaged, untracked, conflicted | paths |
on: [shell.exec]
when: { cel: "git.branch == 'main' && event.data.commands.exists(c, c.program == 'git' && c.args[0] == 'commit')" }
---
Commit on a feature branch, not on {{ git.branch }}.Snapshots and diffs
The snapshot effect stores the work tree as git add --all would stage it (untracked files included,
ignored ones excluded) as a git tree. It works on a throwaway copy of the index: your index, work tree and refs
are untouched. Outside a repository it throws, so guard it with git.repo.
- snapshot stores it in state.toolUse.snapshot; - snapshot: { as: state.turn.start } chooses the key.
| Function | Returns |
|---|---|
diff(snapshot) | changes from the snapshot to the current work tree |
diff(before, after) | changes between two snapshots of one repository |
snapshotFile(snapshot, path) | the text of a root-relative path in the snapshot, null if absent or over 4 MiB |
A change is { path, from, status, additions, deletions }: status is added, modified, deleted,
renamed, copied or typechange; line counts are null for binary files.
on: [tool.call]
when: { cel: "git.repo && event.data.tool == 'Bash'" }
then: [snapshot]on: [tool.result]
let: { touched: "diff(state.toolUse.snapshot).filter(c, glob(c.path, '**/.env*'))" }
when: { cel: "has(state.toolUse.snapshot) && size(touched) > 0" }
then: [block] # at tool.result Claude is told; the files already changedThe snapshot's objects stay in the repository until git gc prunes them.