Skip to content

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:

  1. Bundling: a single commit that touches multiple units (e.g. two tasks in one commit). This makes /np:undo-task impossible: there is no clean git revert for "only this one task".
  2. Splitting: a single unit that spans multiple commits (e.g. "part 1 of task N", "part 2 of task N"). This makes /np:undo incoherent: 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 page

Docs / config / scaffold commits use their own prefixes:

docs(M001): capture milestone context
docs(M001-01): PLAN.md ready for execute
chore: np:new-project scaffold

Decision Drivers

  • Reversibility: /np:undo, /np:undo-task, /np:reset-slice all rely on the 1:1 commit-to-task mapping plus the stable task(M<NNN>-S<NNN>-T<NNNN>) prefix.
  • Legibility: git log --oneline reads 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:undo each map to a well-defined set of commits.
  • Good: git log --oneline reads 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-task has 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-task by 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 calls assertCommittablePaths(files) (gitignore guard) before git add and runs git commit -m 'task(<id>): …'.
  • Executor agents are instructed to never call git directly; always through node np-tools.cjs commit-task <id>.
  • /np:undo-task <M-S-T> uses findCommitByTaskId which greps ^task(<id>):; the prefix is the contract.

More Information

  • Related ADR: ADR-0001. The commit happens in the invoking agent's session, not in a background worker.
  • Related ADR: ADR-0003. Defines the unit-types this rule binds to.
  • Related ADR: ADR-0005. Commits touch files in a single tree at a time.