Plugins · CPO
Work
The work ledger: which changes in the repository are Claude's, what a shell command wrote, and how to undo it.
Many rules need to know which changes are Claude's: refuse to write into a tree holding someone else's
uncommitted work, or refuse to end a turn while Claude's own work is uncommitted. The engine keeps one work
ledger per session, shared by every policy, exposed as work in CEL and templates.
The work object
| Field | |
|---|---|
tracked | the ledger is active for this project |
touched | every path Claude claimed |
mine | dirty paths whose current content is exactly what Claude left |
foreign | dirty paths that are not mine: yours, another session's, or Claude's work edited since |
uncommitted | dirty paths Claude changed, even if edited since: what Claude should commit |
call | null outside a tracked tool use, else { tool, foreign, changed, written, restored } |
call.foreign holds the foreign paths at the tool's call; changed and written (changed and still dirty) are
filled at its tool.result or tool.error; restored lists what restore did.
What the engine records
| When | What |
|---|---|
tool.call of Bash or PowerShell | the content of every dirty path (stored as git blobs, up to 16 MiB each), and which were foreign |
tool.call of Write, Edit, MultiEdit, NotebookEdit | the same ids, and the tool's paths |
tool.result / tool.error | which paths the call changed; those left dirty become Claude's claims |
tool.denied, or a blocked tool.call | the pending call is dropped |
session.end | the ledger is deleted |
- A shell call can change any path; a file tool only its own, so your concurrent edits are not claimed.
- A claim records the exact content Claude left. If you edit the file afterwards, it is no longer
mine. - A claim lives while its path is dirty: once committed, stashed or checked out, it is dropped.
- Paths are what
git statusreports, relative to the root; ignored files are invisible.
Tracking costs a few git calls per writing tool, so it only runs when an active policy references work.,
uses restore, or has a module check. Outside a repository, work.tracked is false and every list is empty.
What a shell wrote
Before a command runs, only >, >> and tee are visible, with unknown content. A heredoc into cat,
sed -i, cp or python -c goes unseen. So at the tool.result of a Bash or PowerShell call, the engine
derives one file.write per regular file the command changed, with applied: true, via: shell, the content
before the call and after it. Every file.write policy thus sees shell writes once they happened.
on: [file.write]
when: { builtin: max-lines, with: { max: 400 } }
then: [restore, block] # a file tool is refused up front; a file a shell wrote is restoredrestore
- restore, or - restore: { paths: <CEL list> }, at tool.result or tool.error only, puts each path back as
it was at the call: the stored content of a path that was dirty, the HEAD version of one that was clean
(deleted if HEAD lacks it). Claude and the user get one warning listing each path. Defaults:
work.call.written, or the event's file on file.write.
A file.write that has not happened yet (applied: false) has nothing to restore. Anywhere else restore
throws, so the action is blocked. ccpp cpo trace only reports what it would restore.
Example: respect other people's work, commit before stopping
on: [file.write]
when: { cel: "event.data.via != 'shell' && size(work.foreign) > 0" }on: [tool.result, tool.error]
when: { cel: "work.call != null && size(work.call.foreign) > 0 && size(work.call.written) > 0" }
then: [restore, block]on: [turn.end]
when: { cel: "size(work.uncommitted) > 0" }A command that only cleans the tree (git commit, git stash, git checkout --) writes nothing dirty, so it
passes.