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

Update 2026-08-07 — the "Mirrors CI check" column is now historical. Once .github/workflows/pre-commit.yml began running pre-commit run --all-files on every PR, the mirrored CI jobs became duplicates: each hook was already running in CI through pre-commit, while the standalone job re-ran the same gate in a second runner. Because GitHub bills every job rounded up to a full minute, those ~7-second jobs cost the same as a 60-second one — measured at ~21% of the repo's total CI bill.

The following jobs were therefore deleted; the hooks in the table above are now the sole definition of each gate: ci.yml / agent-sync, context-drift-check.yml / check-context-drift, context-drift-check.yml / spec-frontmatter-validation, docs-lint.yml / Markdown lint.

Two of them were also worse than the hook they mirrored — check-context-drift was a parallel reimplementation whose backend-module rule could never match, and which missed .context/decisions/ADR-*. Deleting it fixed both gaps.

The ADR's decision is unchanged: one definition, enforced locally and in CI. What changed is that pre-commit.yml is now how it reaches CI, so a second job is pure cost.

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/pre-commit.yml — runs pre-commit run --all-files on every PR. Since the 2026-08-07 consolidation above, this is the ONLY place these gates run in CI.
  • .github/workflows/ci.yml — its gates job now holds the only PR check with no pre-commit equivalent: blocking direct edits to approved specs, which needs PR labels and so cannot run locally. context-drift-check.yml and docs-required-check.yml were deleted once their remaining jobs were folded in here.
  • .github/workflows/docs-lint.yml — retains just frontmatter (docs/scripts/validate-frontmatter.sh has no hook yet; adding one would retire this workflow).
  • 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