Design
Pipeline
git diff --staged -M -U2 -> parse_diff -> prepare (filter, rank, fit) -> prompt (+ style, scopes, branch) -> Haiku structured output -> format_commit -> print / commit / hook.
The model only sees compact, pre-digested input and returns a small JSON object, which keeps cost near $0.0002 per commit.
Hunk ranking
Each hunk gets a score from its changed lines:
- base: 0.05 per meaningful changed line (blank and comment-only lines ignored), capped at 30 lines. A hunk with only blanks and comments scores 0.
- symbol regexes per language find definitions in added and removed lines. A new symbol adds 5 (+1 if public), a removed public symbol adds 5, a symbol present on both sides with a different line (signature change) adds 3.
- "public" means: Python no leading
_; Go capitalized; Rustpub; JS/TSexport; others no leading_.
Worked example: adding def list_users(limit=50) to server.py scores 0.15 + 6 = 6.15; a hunk that only edits a comment scores 0.
Budget fitting
Budget is in estimated tokens (ceil(chars / 4)).
- Lock, generated and binary files become one-line summaries, never diffs. File headers and summaries are a fixed cost.
- Hunks are taken highest-score first if they fit whole (first fit; a too-big hunk is skipped, smaller ones still get a chance).
- If at least 40 tokens remain, the best skipped hunk is truncated to fit and ends with
… N more lines. - Output keeps original file and hunk order. Files with dropped hunks get
… N more hunk(s) omitted (+a -d).
Example with budget 70: a 200-line hunk and two 2-line hunks with new functions: both functions are kept, the big hunk is reported as omitted.
Scope inference
packages/api/x.ts -> api (monorepo roots: packages, apps, libs, crates, services, modules, plugins). Otherwise leading src/lib is stripped and the first directory is used; tests/ maps to test; root files use their stem (except README/LICENSE/CHANGELOG/dotfiles). Candidates are ranked by changed lines. If the config lists allowed scopes, they replace the candidates in the prompt and invalid model scopes are dropped.
Style learning
From git log -50 --format=%s (merges and reverts ignored): share matching type(scope)!: subject, top types and scopes, majority capitalization of the first letter, and whether tickets appear in subjects. A ticket from the branch name (feature/ABC-123-x) becomes a Refs: ABC-123 footer unless the model already mentioned it. Output is always Conventional Commits; history only steers type, scope and capitalization.
Validation
- Unknown type becomes
chore; scope outside the allowlist is dropped (both warn). - Subject: whitespace normalized, trailing period removed, leading past/third-person verb mapped to imperative from a fixed list, first letter lowercased when history is lowercase (acronyms kept), truncated at a word boundary so the full header is at most 72 chars.
- Body is rewrapped at 72 columns, preserving paragraphs and bullet lists.
- A non-empty
breakingadds!and aBREAKING CHANGE:footer.
Cache
Key: sha256 of [model, system prompt, user prompt]. The prompt contains the filtered diff, so any staged change misses the cache. Only the parsed model output is stored; warnings are recomputed on every run.
Hook rules
The hook is a three-line shell script calling python -m commitcraft --hook-run FILE SOURCE. Sources merge, squash, commit (amend and -c) and message are skipped, as is any file that already has non-comment text. Any error is swallowed so commits are never blocked.