How attribution works
The sequenceโ
-
The agent works. Claude Code, Codex and
agyeach write a session transcript for their own purposes โ session resume, history. whodunit only reads them; it does not ask the agent for anything and does not change how the agent runs. -
dun ingestreads them into a local journal. One row per edit: timestamp, agent, session, tool, file path, lines added and removed, a hash per produced line, and the outcome. This happens automatically at commit time, and continuously if you rundun daemon run. -
You commit. The
prepare-commit-msghook looks up which staged files were touched by a recent session, compares the staged lines against the hashes the agent produced, and writes the trailer. -
commit-msgvalidates what was written, so a malformed trailer fails at commit rather than at analysis time.
How the confidence level is decidedโ

staged files match a recent session?
โโโ no โ did we look anywhere real?
โ โโโ no โ undetermined (no transcript directory exists โ
โ โ we could not tell)
โ โโโ yes โ was the agent producing lines at all?
โ โโโ no โ unassisted (a human wrote this)
โ โโโ yes โ unmatched (the agent was working elsewhere)
โโโ yes โ observed
โโโ staged lines match lines the agent produced?
โโโ yes โ intersected
observed means the agent edited these files recently. intersected means
the exact text it produced is what got staged โ the text was not rewritten
in between.
Note the first branch, which is what separates a measurement from a
guess. unassisted asserts that no AI was involved โ a real finding โ and
it is only made when the tooling actually looked somewhere that exists. A
transcript directory that is present and empty is evidence: the agent was
installed, it was watching, and it recorded nothing for this repository.
A directory that is absent is not evidence of anything, so the commit
gets undetermined instead.
Which edits countโ
Any file the agent writes, whichever tool it used. Edit and Write
report the file directly. Since 0.6.1, so does work an agent does through
the shell: dun reads the command for the file it names.
Four shell mechanisms are recognised:
cat > main.go <<'EOF' # > and >> redirects
gofmt -w internal/x.go # gofmt -w
echo x | tee logs/out.txt # tee, and tee -a
sed -i 's/a/b/' cmd/m.go # sed -i, GNU and BSD forms
Python is read separately, and by what it does rather than how it was
invoked โ a heredoc and python -c are treated alike. A command counts
when it actually writes: open(p, 'w'), .write(, .writelines(,
Path(p).write_text( or .write_bytes(, os.replace(, shutil.copy or
shutil.move. The path is resolved whether it is a literal, a variable
holding one, or a Path() built from one, and a loop over a literal list
of paths is unrolled. Python that only reads โ yaml.safe_load(open(f))
โ records nothing.
This matters because of how agents actually work. On one machine's journal
there were 7,263 shell calls against 894 Edit and Write ones. An
agent that edits through a heredoc or a Python one-liner is doing the same
work as one that calls Edit, and it is now attributed the same way.
Measured with the shipped code across 31,873 shell calls in six
repositories, 91.1% of the commands that write a file name a target
dun can extract โ ranging 84.5% to 100% by repository, since it depends
on a project's habits.
A command only counts when a write operator names a file. ls -l x.go
mentions one and authors nothing; mkdir, cp and mv move or create
containers rather than content; and a command that both writes and runs
git โ cat > f <<'EOF' โฆ && git add -A โ is credited for the write.
Three boundaries worth knowing:
- A path built at runtime cannot be read.
open(os.path.join(d, name), 'w')names no file until the program runs, so the edit is recorded as partial rather than guessed at, and a partial file set never producesintersected. - A shell edit carries no diff, so no line counts come with it. Such a
commit reaches
observedrather thanintersected, unless the same lines were seen through another tool as well. - The rules are per-ecosystem, and PowerShell is not among them.
Out-File,Set-ContentandAdd-Contentare not recognised, so a Windows agent using them records nothing โ though>redirects,python -cand git-bash heredocs work there normally.gofmt -wis 8.5% of writes in a Go repository and 0% in a Python one; an unrecognised mechanism costs attribution, never correctness.
What happens when a line is editedโ
One surviving line is enough for intersected. Editing some of the agent's
lines lowers the ratio rather than changing the method, and only editing
every line drops a commit to observed:

A line's hash covers its whole content after trimming whitespace, so reindenting keeps attribution while adding a word to the line does not. And when the agent rewrites a line a human changed, the new text is the agent's own, so the line counts again.
What a formatter does to itโ
A linter or formatter is the ordinary case of "editing some of the agent's lines" that nobody thinks of as editing. Measured against the tool's own hashing and diff code, in a throwaway repository, on a file the agent wrote and a formatter then changed before staging:
| what changed the file before staging | lines matched | result |
|---|---|---|
gofmt on Go the agent wrote gofmt-clean | 9 of 9 | intersected, ratio 1.00 |
| reindent only (spaces to tabs) | 9 of 9 | intersected, ratio 1.00 |
gofmt on Go the agent wrote unformatted | 4 of 9 | intersected, ratio 0.44 |
| quote style and semicolons changed on a JS file | 1 of 5 | intersected, ratio 0.20 |
| every line touched (identifiers renamed) | 0 of 5 | observed |
Two things follow.
Whitespace at the edges of a line is free; anything inside it is not.
Reindentation costs nothing. But a+b and a + b are different lines to the
hash, so a formatter that inserts spaces around operators, changes quote
style, adds semicolons or wraps long lines breaks every line it touches.
The method survives as long as one line does โ the JS case above is still
intersected on a single surviving line โ but the ratio falls steeply
well before the method flips.
So the agent's share is a floor, not a ceiling, and a formatter can lower it a lot. A team whose agent writes code that already satisfies the formatter loses nothing. A team whose formatter rewrites the inside of lines sees a share that under-reads the agent's contribution, by an amount that depends on how much the formatter changes. The method is still honest โ the agent's exact text did not reach the commit โ but read the ratio with that in mind, and do not compare shares across teams with different formatters.
Two limits are worth knowing. The changed_by field on an observed commit
distinguishes an agent rewriting its own work from a human revising it; a
formatter is neither, and reads as a human revision. And running the
formatter from the agent does not help: only the agent's write and edit
tools are hashed into the journal, so a formatter the agent invokes through
a shell command produces text the journal never sees.
The left branch is the part worth reading twice. Both outcomes produce no
attribution, and they are not the same thing: unassisted is a finding โ
the hooks were watching, the journal was readable, and there was no agent
โ while unmatched means an agent was busy somewhere the staged files
are not. A script that generates a file leaves no tool call naming it, so
its commits land in unmatched, and claiming those lines for the agent
that ran the script would be a lie about who wrote them.
declared and inferred sit below observed on the ladder and are
reserved for agents whose stores cannot support file-level or line-level
evidence.
That tree decides which state a commit gets. What each state entitles you to claim โ and the four distinct reasons a commit can end up with no state at all โ is What the numbers mean.
Why hashes rather than commit SHAsโ
Attribution is matched by content hash, never by commit SHA.
A commit does not exist when the observation is recorded, and once it does it may be amended, rebased, squashed or cherry-picked. Hashing what changed, rather than where it landed, keeps attribution correct across all four.
The hashes are one-way. They confirm that a line the agent produced is the line that shipped; they cannot reconstruct the line.
The lookback windowโ
A journal entry counts toward a commit for 30 days.
Seven days was the original value and lost anything that sat over a holiday or a long-running branch โ which is exactly the work most likely to be agent-heavy. The wider window costs about 26ms per commit, roughly 2% of the hook's budget, because the query is indexed.
Line-hash retention is derived from the same constant rather than
configured separately. Pruning hashes the hook would still have matched
would silently turn an intersected commit into an observed one, so
retention_days cannot be set below the lookback.
What happens when it failsโ
Hooks never fail a commit. A hook that blocks work because it could not attribute it would be uninstalled within a day, so every failure path exits zero and stamps a status saying what happened.
degraded is the one that means something broke โ an unreadable journal,
a failed ingest, config that would not load. It is deliberately distinct
from unmatched and unassisted, which are answers rather than faults.
Failures are also appended to a replay log that nothing rotates away, so a determination that failed can be retried once the cause is fixed:
dun replay # what failed, and why
dun replay --apply # retry each one against the journal as it stands now
A retry re-runs the same determination against a journal that may have learned more since โ a transcript ingested after the commit was made carries hashes that were not available at the time. The commit keeps its original trailer either way: git history is not rewritten, and the replay log records the outcome by appending rather than by editing what it already said.
That silence is deliberate but not total โ the errors are recorded:
dun log
shows what the hooks did and every error they swallowed.
Verifying it end to endโ
dun verify
Checks the install, the hooks, the journal, the agents it can find, and the sync target if one is configured โ and names what to fix rather than reporting a bare pass or fail.