Appearance
ADR-0004: Atomic Commit per Task
- Status: Accepted
- Date: 2026-04-14 (revised 2026-04-20)
- Supersedes: None
Context and Problem Statement
Executor agents must produce a legible, reversible git history. Two anti-patterns destroy that property:
- Bundling: a single commit that touches multiple units (e.g. two tasks in one commit). This makes
/np:undo-taskimpossible: there is no cleangit revertfor "only this one task". - Splitting: a single unit that spans multiple commits (e.g. "part 1 of task N", "part 2 of task N"). This makes
/np:undoincoherent: which commit represents the unit?
The question: what is the commit-to-unit mapping that makes task-level, slice-level, and milestone-level undo mechanically implementable?
The Rule
Every completed task produces exactly one git commit. A commit never bundles more than one task. A task never produces zero or two commits.
Non-task units (milestone / slice creation, documentation, capture) may produce their own commits, but those commits never mix with task commits. The task(…): subject prefix is reserved.
Commit message format
Every task-producing commit uses the exact prefix:
task(M<NNN>-S<NNN>-T<NNNN>): <task title>Examples:
task(M001-S001-T0001): add login form
task(M001-S001-T0002): wire login handler
task(M001-S002-T0001): render profile pageDocs / config / scaffold commits use their own prefixes:
docs(M001): capture milestone context
docs(M001-01): PLAN.md ready for execute
chore: np:new-project scaffoldDecision Drivers
- Reversibility:
/np:undo,/np:undo-task,/np:reset-sliceall rely on the 1:1 commit-to-task mapping plus the stabletask(M<NNN>-S<NNN>-T<NNNN>)prefix. - Legibility:
git log --onelinereads like a plan-trace; each line corresponds to one task completion. - Audit: code review proceeds per-task; reviewers see exactly what one task changed.
- Grep-ability:
git log --grep='^task(M001-S001-'cleanly filters one slice;git log --grep='^task(M001-'filters one milestone.
Considered Options
- One atomic commit per task: the rule stated above. (CHOSEN)
- Squash-at-slice-boundary: many small commits during execution, squashed at slice-end.
- One commit per file change: commit granularity tied to file count, not semantic task count.
- No commit discipline: the executor commits whenever it feels like it.
Decision Outcome
Chosen: "One atomic commit per task", because it is the only option that makes per-task revert implementable as mechanical git revert <sha> operations. Every other option forces /np:undo-task into either "impossible" (squash, no-discipline) or "brittle" (heuristics about "which files belong to task N").
Consequences
- Good:
/np:undo-task,/np:reset-slice, and/np:undoeach map to a well-defined set of commits. - Good:
git log --onelinereads as a progress report;git log --grep='task(M001-'filters one milestone cleanly. - Good: code review can proceed per-task.
- Good: no daemon required to enforce atomicity (ADR-0001).
- Bad: small tasks produce many commits. Accepted; modern git tooling handles thousands without trouble.
- Neutral: PR-level squash-merging is compatible, provided per-task atomic commits are preserved on the feature branch.
Pros and Cons of the Options
One atomic commit per task — chosen
- Good: implementable as mechanical revert operations.
- Good: produces self-documenting git history.
- Good: well-understood git-discipline pattern with no novel enforcement cost.
- Bad: commit count grows linearly with plan complexity. Accepted.
Squash-at-slice-boundary — rejected
- Good: produces a tidy "one commit per slice" history on main.
- Bad: destroys task-granularity undo:
/np:undo-taskhas no commit to revert once the slice is squashed. - Bad: crash-recovery loses intermediate state.
- Bad: a verifier cannot isolate a single task's diff after merge.
One commit per file change — rejected
- Good: produces the smallest possible commits.
- Bad: couples commit count to file count, not semantic task count.
- Bad: breaks the mental model: readers can no longer equate "one entry in git log" with "one task in the plan".
- Bad: commit messages become meaningless ("add line to foo.md") rather than intentional.
No commit discipline — rejected
- Good: requires the least process.
- Bad: breaks
/np:undo-taskby construction. - Bad: makes per-task code review impossible.
- Bad: two executor agents working the same plan at different times produce non-comparable histories.
Enforcement
lib/git.cjs.commitTask(taskId, files, message)is the sole sanctioned entry point for task commits. It callsassertCommittablePaths(files)(gitignore guard) beforegit addand runsgit commit -m 'task(<id>): …'.- Executor agents are instructed to never call
gitdirectly; always throughnode np-tools.cjs commit-task <id>. /np:undo-task <M-S-T>usesfindCommitByTaskIdwhich greps^task(<id>):; the prefix is the contract.
