Skip to main content

ADR-013: Pre-Commit Framework Adoption for Local Enforcement

Status: Accepted Date: 2026-04-20 Deciders: Vivek (CEO), Claude (consultation) Supersedes: None Related: CLAUDE.md / AGENTS.md rule 10 (context-file updates), rule 12 (spec intake), .github/workflows/context-drift-check.yml, .github/workflows/docs-lint.yml


TL;DR

Adopt pre-commit (Python framework) as the canonical local-enforcement tool for this repository. All repository-wide gates that CI currently enforces post-push are mirrored as pre-commit hooks so they fire at commit time instead of after push. Same .pre-commit-config.yaml runs in CI via pre-commit/action@v3, giving provable parity between local and server-side gates.

We are NOT using: lefthook, husky + lint-staged, plain .githooks/ + git config core.hooksPath.


Context

Audit of PR #5 (Bazel scaffolding) on 2026-04-20 revealed a recurring pattern: agents produce PRs that violate rules already codified in AGENTS.md / CLAUDE.md and enforced by CI. Examples from that PR:

  • MODULE.bazel was modified without updating .context/TECH_STACK.md — caught by check-context-drift in CI, but only after push.
  • Two new SPEC-*.md files had markdown-bold metadata instead of YAML frontmatter — caught by spec-frontmatter-validation in CI, only after push.
  • Workflow permission bugs in .github/workflows/ci.yml and docs-preview.yml caused a cascade of Resource not accessible by integration failures, blocking the downstream backend/frontend/preview jobs.

The common shape of these failures is:

  1. A rule exists in AGENTS.md / CLAUDE.md.
  2. CI enforces it correctly.
  3. The agent (or human) commits and pushes without reading or satisfying the rule.
  4. CI fails, the author fixes, re-pushes, CI re-runs. Minutes lost per cycle, habituated tolerance of red PRs, and wasted CI minutes.

The root cause is enforcement latency. Rules buried in markdown are passively present but not actively enforced at authorship time. By the time CI fails, the agent's commit is already shared state.

The fix is to move enforcement as close to the agent as possible: commit time, local, blocking. This is the "shift left" principle applied to repo discipline.


Decision

Adopt pre-commit as the local-enforcement framework. Ship a .pre-commit-config.yaml at the repo root that mirrors the gates CI already enforces:

HookMirrors CI checkEnforcesInitial rollout
context-driftcontext-drift-check.yml / check-context-driftDependency file changes require matching .context/*.md update (CLAUDE.md rule 10)Shipping
spec-frontmattercontext-drift-check.yml / spec-frontmatter-validationdocs/specs/**/SPEC-*.md must start with valid YAML frontmatterShipping
agent-syncci.yml / agent-syncAGENTS.md and CLAUDE.md must be byte-identical (CLAUDE.md rule near "Agent rule file hierarchy")Shipping
actionlint(new CI gate, co-shipped).github/workflows/*.yml must pass structural validationShipping
markdownlintdocs-lint.yml / Markdown lintdocs/**/*.md must pass markdownlint-cli2Deferred — ~68 pre-existing errors; enabled in the same PR that clears the debt (see "Implementation" step 4)
detect-secrets(new CI gate)Block commits that add common secret patterns (Hard rule 6)Deferred — needs a curated .secrets.baseline generated and audited first

Local install contract:

  • One-time per clone: pre-commit install. Idempotent. Writes .git/hooks/pre-commit.
  • Each commit: pre-commit invokes hooks, passing only staged files. Hooks that fail block the commit.
  • A bootstrap script (tools/scripts/setup-dev.sh) runs pre-commit install so new contributors and agent worktrees pick it up automatically.

CI parity: a new .github/workflows/pre-commit.yml runs pre-commit run --all-files on every PR. If the local hook is skipped (e.g., git commit --no-verify), CI catches it. Belt and suspenders.


Alternatives considered

OptionProsConsVerdict
pre-commit (Python framework) — chosenHuge ecosystem of ready-made hooks; language-agnostic; reproducible pinned versions; CI parity trivial via pre-commit/action@v3; polyglot-first design matches this monorepo; Python already present (ml/ via uv)First-run cold start (~10–30s per machine); YAML config can sprawl at 15+ hooksAdopted
lefthook (Go binary)Single binary, no runtime; parallel execution by default; clean YAMLSmall ecosystem (mostly write your own invocations); CI parity is DIY (rewrite hook definitions twice); smaller communityReject. Migrate later if hook runtime hurts.
husky + lint-staged (Node)Trivial in Node repos; lint-staged is best-in-class for staged JS/TS filtering; ubiquitous in frontend worldWrong language center-of-gravity for a polyglot monorepo; backend-only contributors shouldn't need pnpm for a context-drift shell script; hook config sprawls into package.jsonReject. Wrong fit.
Plain .githooks/ + git config core.hooksPathZero dependencies; transparent bash; trivially customizableWrite all plumbing yourself (staged-file filtering, caching, parallelism); each dev must git config core.hooksPath after clone; scales poorly past 3–4 hooksReject. Re-invents what pre-commit already solved.

Consequences

What becomes easier

  • Agents catch their own mistakes before push. An agent editing MODULE.bazel without touching .context/TECH_STACK.md gets blocked locally, sees the error, fixes it in the same iteration. No CI round-trip.
  • Red PRs become exceptional, not routine. Developers stop tuning out CI email noise because it stops firing for preventable issues.
  • One source of truth for gates. .pre-commit-config.yaml defines what's checked; CI just invokes it. No drift between local and server enforcement.
  • New-hire onboarding is shorter. tools/scripts/setup-dev.sh sets up hooks; they find out about repo conventions by hitting the gates, not by reading markdown.
  • Agents in worktrees inherit enforcement. A new Claude Code worktree runs pre-commit install in bootstrap and gets the full gate suite.

What becomes harder

  • First commit per clone is slow. Cold-start installs hook environments (~10–30s). Acceptable one-time cost; subsequent commits are sub-second.
  • Hooks require Python 3 on the dev machine. Already true for this monorepo (ml/ uses uv), but contributors who were purely on Kotlin/TypeScript now need a Python runtime. Mitigation: document brew install python in the bootstrap script, or let uv provide it.
  • Adding a new gate means updating two files (.pre-commit-config.yaml + the CI workflow that invokes it). Small overhead; the payoff is CI↔local parity.
  • Hooks can be bypassed with --no-verify. CI is the backstop. If bypass becomes a pattern, add commit-message lint enforcing a justification for --no-verify use, or promote the most-bypassed hook to a server-side branch protection rule.

Risks

  • Hook perf regression at scale. If the repo grows and hooks run on many files per commit, total wall-time could exceed a few seconds and developers will start --no-verify-ing. Mitigation: measure; most hooks already filter to staged files; if needed, migrate to lefthook for parallelism. The underlying hook commands are portable, so migration cost is bounded.
  • Hook-version drift between devs. Mitigated by pre-commit autoupdate + committing the updated .pre-commit-config.yaml. CI uses the same pinned versions.
  • False positives blocking legitimate commits. A hook that misfires teaches developers to bypass it. Mitigation: each hook must have a clear error message pointing to the remediation. We iterate on hook rules via PRs to .pre-commit-config.yaml, reviewed like any other code.

Implementation

Rolled out in sequenced PRs (avoiding a broken intermediate state):

  1. Framework landing PR (this one): .pre-commit-config.yaml + three shell-out scripts (tools/scripts/check-agent-sync.sh, check-context-drift.sh, validate-spec-frontmatter.sh) + .github/workflows/pre-commit.yml + tools/scripts/setup-dev.sh update. ADR-013 itself lands here so the rationale is committed alongside the change. core.hooksPath is unset at both --local and --worktree scopes by the bootstrap script — Claude Code worktrees inherit a worktree-scoped value, so both must be cleared.
  2. Markdown-lint debt cleanup PR: runs markdownlint-cli2 --fix docs/**/*.md, lands the fixes, and re-enables the markdownlint hook in .pre-commit-config.yaml in the same commit.
  3. detect-secrets PR: generate .secrets.baseline, audit for false positives, commit baseline, enable the hook.
  4. AGENTS.md additions: onboarding reference to tools/scripts/setup-dev.sh, workflow-permissions rule, strengthened spec-template rule, pre-commit checklist.

The legacy tools/git-hooks/pre-commit (which auto-synced CLAUDE.md on AGENTS.md changes) is superseded by the check-only agent-sync hook. Auto-mutation hooks are explicitly rejected by this ADR — hooks are gates, not build steps. The legacy script is left in the tree for one cycle so users mid-migration aren't silently broken; removal lands once the team has bootstrapped on the new framework.


When to reconsider

This decision is closed. Do not revisit unless:

  1. Hook runtime becomes a regular pain point. If a meaningful fraction of commits take >5 seconds at the hook stage and developers are working around it, evaluate lefthook for its parallelism. Most likely resolution: keep pre-commit, profile slow hooks, fix them. Migration to lefthook stays on the table as a last resort.
  2. Python becomes unavailable on target machines. Extremely unlikely in this repo (ml/ requires it), but if ml/ is ever extracted, re-evaluate.
  3. Hooks turn into coupling. If agents start depending on side effects of hooks (hooks mutating files, hooks generating code), stop and untangle — hooks should be gates, not build steps.
  4. A bypass pattern emerges. If git commit --no-verify shows up in >5% of commits, don't tighten hooks — find out why they're being bypassed and fix the underlying hook quality.

Do NOT revisit because:

  • Someone says "pre-commit is slow" without a measurement.
  • A blog post describes a new framework.
  • You want to tune a single hook (edit .pre-commit-config.yaml — that's not an ADR change).

The thought process is captured here. Refer back instead of re-deriving.


References

  • CLAUDE.md / AGENTS.md rule 10 (context-file updates) — the rule that pre-commit now enforces locally.
  • .github/workflows/context-drift-check.yml — CI gate that the context-drift hook mirrors.
  • .github/workflows/docs-lint.yml — CI gate that the markdownlint hook mirrors.
  • .github/workflows/ci.yml — hosts the agent-sync check that the local hook mirrors.
  • ADR-006 (task-files cascading context) — related discipline around context files.
  • PR #5 (Aucert/aucert#5) — the forcing function that surfaced the enforcement-latency problem.

Changelog

DateChangeAuthor
2026-04-20Initial decision capturedVivek + Claude