Skip to main content
Version: Next

How purpose is classified

Every commit gets a purpose โ€” feature, fix, test, refactor, docs, config, chore, migration, dependency, or other. It is what the "work by purpose" panels, the rework rate, and the productivity split by work type are built on, so it is worth knowing exactly where it comes from.

No model is involved. Purpose is decided by three deterministic rules, tried in order. The first rule that matches wins. If none matches, the purpose is other โ€” never a guess.

It reads the message you already wroteโ€‹

The tool does not add, rewrite, or append anything to a commit message to classify it. It reads the first line of the message the developer wrote and looks for a Conventional Commits prefix โ€” type(scope)!: description. If one is there, that is the purpose:

prefixpurpose
featfeature
fixfix
testtest
refactor, style, perfrefactor
docsdocs
chorechore
ci, buildconfig

This is the highest-confidence rule, and it is the only rule that ever produces feature or fix. Nothing about the files changed or the shape of the diff can promote a commit to either.

What the tool does write into a commit is the attribution trailer at the bottom โ€” status, agent, method, and the rest, described in How attribution works. That block is separate from purpose and carries no purpose field. Purpose is computed at sync time and stored alongside the commit; the message is left as the author wrote it.

Then the paths, if every file agreesโ€‹

Without a prefix, the tool looks at the changed files. A category is assigned only when every changed file matches the same pattern. A source file committed together with its test is a mixed change and falls through rather than being labelled by whichever pattern happened to match first.

Patterns, tried in this order โ€” narrower signals first:

purposeevery changed file must beโ€ฆ
dependencya manifest or lockfile: go.mod, go.sum, package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, Cargo.toml, Cargo.lock, Gemfile, Gemfile.lock, requirements.txt, poetry.lock, pyproject.toml
migrationon a path containing migration
test_test.go, under /test/ or /tests/, containing .test., or ending _spec.rb
docsending .md, under docs/, or a README
configunder .github/, ending .yml / .yaml / .toml / .gitignore, or a Dockerfile

Matching is case-insensitive.

Then the shape of the diff, weaklyโ€‹

The last rule has one case: a change touching six or more files with no other signal is called refactor. Anything smaller with no prefix and mixed paths stays other. This is deliberately weak โ€” it exists because a broad multi-file change with no message convention is more often a restructuring than anything else, and it is used only when the two stronger rules have already declined.

What this means when you read a dashboardโ€‹

feature and fix measure the team's commit habit as much as its work. A team that writes feat: and fix: gets an accurate split. A team that writes add export button gets other for that commit, and their dashboard shows few features and a large other slice. That is a signal about commit-message discipline, not about what the AI did. The fix is on the team's side: adopt the prefix, and the classification follows without any change to the tool.

The rework rate is a fix rate, not a defect rate. Every panel that shows rework counts commits the author labelled fix:. It does not count bugs found, incidents, or reverts. A team that never writes fix: has a rework rate of zero, which is not the same as having no rework.

other is honest, not a failure. The design target is 70โ€“80% of commits classified with no model involvement. The remainder is reported as other rather than pushed into a plausible-looking bucket, for the same reason absence is never reported as zero: a wrong label that looks right is worse than an honest gap.

Complexity is not measured. Where a dashboard splits work by complexity, it is using the number of files changed as a stand-in, and says so in the panel's tooltip.

Sourceโ€‹

The classifier is internal/purpose/purpose.go in the repository. It is small enough to read in a few minutes, and reading it is the definitive answer to "why did this commit get that label".