ccppccpp home
Browse the documentation

Plugins · CPO

State and git

Remember values between events, read the repository status, snapshot the work tree and diff it.

State

ScopeShared byCleared
sessionevery event of the sessionat session.end
turnevents until the next promptat turn.prompt
toolUsetool.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]
  • set stores JSON values; all of a set'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 trace reads them but never writes.

Example: remember a command at tool.call and use it at its tool.result, in two policy files.

remember-command.policy.md
on: [tool.call]
then: [{ set: { state.toolUse.command: "event.data.input.command" } }]
after-git.policy.md
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
repofalse outside a repository (or without git); every other field is then empty
rootthe work tree root; git paths are relative to it
head, branch, upstreamcommit id, branch (null when detached), upstream (null if none)
ahead, behindcommits ahead of / behind the upstream
dirtyany change, untracked files included
changes[{ path, from, index, worktree, conflicted }], codes from git status --porcelain=v2
staged, unstaged, untracked, conflictedpaths
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.

FunctionReturns
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.

snapshot-bash.policy.md
on: [tool.call]
when: { cel: "git.repo && event.data.tool == 'Bash'" }
then: [snapshot]
bash-touched-env.policy.md
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 changed

The snapshot's objects stay in the repository until git gc prunes them.

Edit this page on GitHub