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.bazelwas modified without updating.context/TECH_STACK.md— caught bycheck-context-driftin CI, but only after push.- Two new
SPEC-*.mdfiles had markdown-bold metadata instead of YAML frontmatter — caught byspec-frontmatter-validationin CI, only after push. - Workflow permission bugs in
.github/workflows/ci.ymlanddocs-preview.ymlcaused a cascade ofResource not accessible by integrationfailures, blocking the downstream backend/frontend/preview jobs.
The common shape of these failures is:
- A rule exists in
AGENTS.md/CLAUDE.md. - CI enforces it correctly.
- The agent (or human) commits and pushes without reading or satisfying the rule.
- 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:
| Hook | Mirrors CI check | Enforces | Initial rollout |
|---|---|---|---|
| context-drift | context-drift-check.yml / check-context-drift | Dependency file changes require matching .context/*.md update (CLAUDE.md rule 10) | Shipping |
| spec-frontmatter | context-drift-check.yml / spec-frontmatter-validation | docs/specs/**/SPEC-*.md must start with valid YAML frontmatter | Shipping |
| agent-sync | ci.yml / agent-sync | AGENTS.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 validation | Shipping |
| markdownlint | docs-lint.yml / Markdown lint | docs/**/*.md must pass markdownlint-cli2 | Deferred — ~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) runspre-commit installso 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
| Option | Pros | Cons | Verdict |
|---|---|---|---|
pre-commit (Python framework) — chosen | Huge 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+ hooks | Adopted |
lefthook (Go binary) | Single binary, no runtime; parallel execution by default; clean YAML | Small ecosystem (mostly write your own invocations); CI parity is DIY (rewrite hook definitions twice); smaller community | Reject. 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 world | Wrong 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.json | Reject. Wrong fit. |
Plain .githooks/ + git config core.hooksPath | Zero dependencies; transparent bash; trivially customizable | Write all plumbing yourself (staged-file filtering, caching, parallelism); each dev must git config core.hooksPath after clone; scales poorly past 3–4 hooks | Reject. Re-invents what pre-commit already solved. |
Consequences
What becomes easier
- Agents catch their own mistakes before push. An agent editing
MODULE.bazelwithout touching.context/TECH_STACK.mdgets 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.yamldefines what's checked; CI just invokes it. No drift between local and server enforcement. - New-hire onboarding is shorter.
tools/scripts/setup-dev.shsets 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 installin 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 pythonin 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-verifyuse, 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 tolefthookfor 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):
- 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.shupdate. ADR-013 itself lands here so the rationale is committed alongside the change.core.hooksPathis unset at both--localand--worktreescopes by the bootstrap script — Claude Code worktrees inherit a worktree-scoped value, so both must be cleared. - Markdown-lint debt cleanup PR: runs
markdownlint-cli2 --fix docs/**/*.md, lands the fixes, and re-enables themarkdownlinthook in.pre-commit-config.yamlin the same commit. - detect-secrets PR: generate
.secrets.baseline, audit for false positives, commit baseline, enable the hook. - 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:
- 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
lefthookfor its parallelism. Most likely resolution: keeppre-commit, profile slow hooks, fix them. Migration tolefthookstays on the table as a last resort. - Python becomes unavailable on target machines. Extremely unlikely in this repo (ml/ requires it), but if ml/ is ever extracted, re-evaluate.
- 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.
- A bypass pattern emerges. If
git commit --no-verifyshows 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 thecontext-drifthook mirrors..github/workflows/docs-lint.yml— CI gate that themarkdownlinthook mirrors..github/workflows/ci.yml— hosts theagent-synccheck 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
| Date | Change | Author |
|---|---|---|
| 2026-04-20 | Initial decision captured | Vivek + Claude |