ADR-0028: Canonical Git Hooks (v1)
Accepted Cross-Project UniversalDate: 2026-05-27 Iteration: v1 — plain core.hooksPath shell scripts. Lefthook + Composer/npm-distributed package are documented future evolutions, not built.
Context
CI gates each territory at PR time, but several recent CI failures were preventable locally and only surfaced after a push round-trip. The proximate trigger for this ADR was the ADR-0027 adoption PR (Back-to-code/isms#5):
tests/Unit/was empty on disk; git did not track it. On a fresh CI checkout, Pest's Unit testsuite resolved to a missing directory and exited 2 — failingPHP TestsandPHP Arch Tests. Acomposer testrun against a fresh clone would have surfaced the gap immediately.- Three
symfony/*CVEs disclosed 2026-05-26 (one day before the PR) failedComposer Audit. Acomposer auditstep would have caught them before the push.
Each territory's tooling already runs these checks; the question was whether to shift them left to the local machine via git hooks, and whether to standardize the hook shape across territories so the war-room's convergent-doctrine posture extends to the local feedback loop.
The war-room operates under the Concurrent General Operation doctrine — multiple sessions against multiple territories simultaneously. Inconsistent local hooks amplify friction: a hook that runs composer test in one territory and doesn't in another trains a different commit reflex per territory.
The problem for AI agents and humans alike
Soldiers (Engineer, Medic, Armorer) verify their work per their SOPs before reporting completion. The verification commands in the SOPs (e.g., composer test, composer audit, npm run build) are exactly the gates a pre-push hook should run. Today the SOPs make verification a soldier responsibility checked in the execution report; a hook makes it a structural property of the territory itself — a soldier or human cannot push without the gates passing.
Decision
v1 = plain core.hooksPath + shell scripts in a .githooks/ directory, copied from the canonical template at /templates/githooks/.
Hook framework choice (the comparison matrix between plain hooks, husky, and lefthook is preserved below in §Options Considered) is resolved in favor of the simplest thing that closes the immediate gap, with a deliberate runway to the next iteration once adoption maturity reveals what's actually needed.
Hook gates
| Stage | Target latency | Mandatory gates | Optional gates (territory's call) |
|---|---|---|---|
| pre-commit | <10s | composer pint:check; npm run lint; npm run format:check; npm run vue-tsc | composer rector:check (if rector.php exists) |
| pre-push | <180s | composer test; composer audit; npm run build | npm run test:pipeline; composer test:unit:coverage; e2e tests |
Pre-commit catches formatting / lint / typecheck regressions before they enter history. Pre-push catches the full test + audit + build surface before the commit reaches remote. The split keeps edit-commit-edit cadence fast while gating the actual "shipping" moment.
composer audit is mandatory pre-push specifically because freshly-disclosed CVEs (like the symfony advisories on 2026-05-26) bypass every other gate — code didn't change, dependencies didn't change, but the advisory database did. The audit is the only gate that catches that class.
Per-territory adaptation
Each territory's .githooks/pre-commit and .githooks/pre-push start as a copy of the canonical template, then:
- Add territory-specific gates (kendo's coverage gate, BIO's e2e, etc.).
- Remove gates that don't apply to the current state of the territory (e.g.
rector:checkifrector.phpdoesn't exist yet — Engineer's discretion at scaffold time). - Document the adaptation in the territory's CLAUDE.md under the same "canonical config + documented deviations" convention used for
pint.json,oxlintrc.json, etc.
The canonical scripts auto-detect backend/ and frontend/ subdirectory presence and skip the missing half — single-stack territories (fs-packages npm-only, phpstan-warroom-rules composer-only) work without modification.
Composer script naming prerequisite (v1.1 amendment 2026-05-28)
The canonical pre-commit and pre-push scripts invoke composer scripts by canonical names: pint:check, rector:check, stan, test, audit. Territories that scaffolded fresh from /templates/ (ISMS) already use these names; territories that pre-date the canonical (kendo, ublgenie, entreezuil, emmie, BIO, daymate-api) commonly use territory-grown variants (pint:test, rector:test, phpstan, etc.).
A territory cannot adopt the canonical hooks without first aligning its composer scripts — without the rename, the hook fails at the very first gate with Command "pint:check" is not defined. Did you mean … pint:test. Surfaced via kendo M14 verification (2026-05-28, war-room field report reports/kendo/field/2026-05-28-cartographer-m14-adr-0028-verification.md): kendo's canonical pre-commit/pre-push are structurally unrunnable against current composer.json solely because of the script-name mismatch.
Adoption prerequisite: before copying /templates/githooks/{pre-commit,pre-push} into a territory's .githooks/, ensure that territory's backend/composer.json exposes the canonical script names. Rename territory-grown variants (or add canonical names as aliases pointing at the existing implementations). This is a mechanical pre-step, not a discretionary one — the canonical hooks invoke these names verbatim. Touching composer.json cascades into every CI workflow that calls the old names + every soldier SOP that references them; budget the rename as a precondition mission, not part of the hook-copy itself.
A future v2/v3 may absorb this via a name-mapping config layer (e.g. .githooks-config.yml mapping territory names to canonical gates), but v1 keeps the canonical transparent at the cost of requiring fleet-wide convergence on script names.
Per-clone setup
Each new clone must run once:
git config core.hooksPath .githooksThis is the one piece of friction v1 accepts. Composer post-install-cmd automation is a documented future option (see §Future Iterations); not built in v1 to keep the canonical transparent. Each territory adds a "First-time setup" line to its README.md pointing at the command.
Bypass policy
War-room CLAUDE.md already mandates: NEVER skip hooks with --no-verify unless the Commander explicitly requests it. This ADR inherits that rule unchanged — a failing hook is a fix-the-root-cause signal, not a bypass signal.
Superseded in part by Amendment 2 (Accepted, 2026-09-24). The gate table above and this bypass policy describe the full-tree hooks. Amendment 2 below replaces both.
Amendment 2 — Light hooks (Accepted, 2026-09-24)
AcceptedRuled by the Commander on 2026-09-24. The open points below stay open until settled individually.
Why
The full-tree gate set never met its own latency targets on a real territory, and the costs it creates now outweigh what it catches.
- Latency. The targets are <10 s for pre-commit and <180 s for pre-push. On lokalekeuze the full-tree gates alone measured Pint 130 s, Rector 335 s, vue-tsc 110 s, oxlint 22 s and oxfmt 11 s (LK-0833, 2026-09-24). Pest runs on top of those.
- The push dies after the gates pass. Git opens the ssh connection before
pre-pushruns, and a gate set that outlives GitHub's idle window prints "gates passed" and then fails with exit 141. That happened six times on lokalekeuze on 2026-09-02, and on every push on 2026-09-24. - A green suite reported as a failure. Composer's default 300 s process timeout kills
composer testinsidepre-pusheven when the suite passes, so the push reports a failure with no failing test (wijs, WR-1695). - Blocked by someone else's failure. On emmie, a pre-commit failure inherited from the base branch blocked every commit territory-wide (2026-09-16). The hook refused honest work over a defect the committer did not introduce.
- The bypass rule stopped meaning anything. Because of the above,
--no-verifybecame the daily norm. A rule that is broken every day spends its authority, including on the day it would have caught something.
Meanwhile CI already runs every one of these gates and is the authority (see § Enforcement: "the hook is a local accelerator, not a CI replacement"). An accelerator that takes longer than the thing it accelerates is not one.
Decision
The canonical hook shape becomes the light shape that kendo already runs and lokalekeuze adopted in #766 (LK-0833):
| Stage | Target | Gates |
|---|---|---|
| pre-commit | a few seconds | Staged files only. Pint writes the staged PHP files, and oxlint --fix then oxfmt --write (via lint-staged) write the staged frontend files; the hook re-stages them. A staged file that also has unstaged changes gets a check-only run (--test), never a rewrite, so the hook cannot fold unstaged work into the commit. |
| pre-push | under a minute | Scoped to the push range, read from git's stdin contract. When the range touches backend/: PHPStan and composer audit. When it touches frontend/ outside *.md: vue-tsc. When it is docs-only or deletion-only: nothing. When git passes nothing on stdin: every gate runs (fail closed). |
| CI only | — | Pest (Unit, Feature, Architecture), Rector, the frontend build, Vitest, and every other gate the territory's CI runs. |
This reverses two positions deliberately:
- Hooks may write. Script revision v2 refused a resolved script that writes, such as
pintorformat. The light pre-commit writes, but only the staged files, and it re-stages them. The partially-staged rule above is what makes a writer safe here. - Pest leaves the hooks. Soldiers still run the full suite as part of the verification recorded in their execution reports (soldier SOP § Verify). That record, not a hook, is the pre-push evidence for work the war room produces.
composer audit stays in pre-push. It is the one gate that catches a newly published advisory on unchanged code, and it costs about 2 s.
Bypass policy (replaces the v1 policy)
With checks this light, a bypass should be rare again. --no-verify is permitted when the pushed tree has been verified by the territory's full CI-equivalent gate set, and the PR says so. A bypass for any other reason is still a Commander call.
Open points (to settle individually)
- SIGPIPE safety. #766 deliberately drops the LK-0485 protection, so
git commit … | headcan again kill a hook mid-run while the caller reads exit 0. Either keep that protection in the canonical, or rule that it is not needed. - The war-room repo's own hooks are excluded. This repo has no branch protection, and its
pre-pushVitePress build is the only control that stops a brokenpresentation/commit from reachingmain. Amendment 2 does not apply to it. - kendo's pre-commit formats only
backend/app/, not tests. kendo also re-stages a partially staged file whole, which is the hazard the partially-staged rule closes. The canonical takes lokalekeuze's version (LK-0833 D6), and kendo converges on it.
Rollout
/templates/githooks/takes the light shape, ported from lokalekeuze #766.- WR-1114, the pending propagation of script revision v3 to nine territories, is retired unbuilt. Propagate the light shape instead, one territory at a time. emmie goes first, as its own ticket, because its pre-commit problems are recorded.
- The Implementation table below records each territory's shape as it converges.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
Plain core.hooksPath + .githooks/ shell scripts (chosen for v1) | Accepted | Zero deps, fully transparent (every gate visible in a 50-line bash file), no install step beyond a one-time git config. Closes the immediate gap (the PR #5 failure class) without locking the war-room into a framework choice before adoption maturity reveals the actual ergonomics needs. |
| Lefthook | Deferred to Stage 2 | Parallel hook execution (real speedup on pre-push), single lefthook.yml config, language-agnostic. Best ergonomics for mixed PHP+Vue territories — but adds a binary-install step per dev machine (Homebrew/asdf), and the parallelism benefit is invisible until pre-push latency actually bites. Revisit after 3+ territories have adopted v1 and the speed pain is real. |
| Husky | Rejected | npm-postinstall auto-wires hooks — works well for JS-only projects but couples a PHP-stack gate to a JS tool. Backend-only contributors would need to npm install even when they never touch frontend. Wrong tool for the war-room's mixed-stack territories. |
Composer/npm-distributed package (e.g. script-development/githooks) | Deferred to Stage 3 | The pattern works (precedent: phpstan-warroom-rules as a Composer-distributed canonical). Updates propagate via dependency bump rather than copy-paste-and-drift. But the v1 canonical is ~80 lines of bash — packaging it before there's actual cross-territory drift is premature. Promote when the copy-paste maintenance burden becomes visible. |
| CI-only (status quo) | Rejected | The PR #5 round-trip cost was ~10 minutes (push, wait for CI, read failures, two follow-up commits). Shifting the same gates left to the local machine collapses that to a single iteration. |
Consequences
Positive
- Failures surface locally, not after a push round-trip. Edit→fix→commit replaces edit→push→read-CI→fix→push.
- One canonical pattern across territories. A soldier who learns kendo's pre-push gates already knows ISMS's pre-push gates (modulo the documented per-territory adaptations).
composer auditbecomes a mandatory daily-driver gate. Freshly-disclosed CVEs surface at push time, not at the next CI run.- Zero framework lock-in. The v1 shape can graduate to lefthook (v2) or a distributed package (v3) without rewriting the gate definitions — only the wrapper changes.
Negative
- Sequential execution. Plain bash runs gates in series; pre-push on a fully-populated territory can hit 2–3 minutes. The lefthook upgrade path exists specifically to address this if the friction proves real.
- One-time per-clone setup.
git config core.hooksPath .githooksis not automatic. Friction is small (one line inREADME.md) but real, and forgetting it silently bypasses the hooks. - Cross-platform shebang. WSL/Linux/macOS and Git-Bash on Windows work; PowerShell-only does not. Acceptable given current contributor environments.
- Adoption is opportunistic. Each territory adopts on its own timeline — no fleet-wide flag day. Inconsistency persists until propagation completes.
Risks
- Risk: hooks become a "checkbox theater" if a contributor learns to bypass routinely with
--no-verify. — Mitigation: the bypass policy is already in war-room CLAUDE.md; this ADR inherits it. A hook bypassed in a commit is a CI failure later, which is itself a feedback signal. - Risk: a slow gate (e.g. a flaky
composer auditnetwork call) trains contributors to bypass. — Mitigation: §Future Iterations documents the lefthook parallel-execution path explicitly. If pre-push latency exceeds ~3 minutes routinely, that's the trigger to promote Stage 2. - Risk: the per-territory copy diverges silently from the canonical over time. — Mitigation: the next Adjutant registry sweep flags
.githooks/files in each territory and diffs them against the canonical; cross-territory drift becomes visible at quarterly cadence rather than waiting for a "wait, why doesn't this work the same?" mission.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
| Hook scripts present in territory | Adjutant registry sweep (Level 6 passive monitoring) | .githooks/pre-commit and .githooks/pre-push per territory |
| Hooks wired (per clone) | Contributor responsibility per territory README | git config core.hooksPath .githooks |
| Mandatory gates present in each territory's hooks | Adjutant diff vs /templates/githooks/ canonical | The "Mandatory gates" table in §Decision |
| Bypass discipline | War-room CLAUDE.md Git Safety Protocol (existing) + spy/soldier SOPs | All commits across all territories |
Hooks themselves are not gated at CI — CI is the authority that catches what the hook missed. The hook is a local accelerator, not a CI replacement.
Future Iterations
Captured here so the v1 acceptance does not lose the deliberate roadmap.
Two numbering axes — keep them apart (settled 2026-08-31). This roadmap numbers the mechanism and its stages are Stage 2 / Stage 3; neither is built, and the mechanism is still plain
core.hooksPathshell scripts. The script revision — the content of those shell scripts — is a separate axis numbered v1 / v2 / v3, currently at v3 (2026-08-31).The two collided at "v2" because both were numbered from one. The mechanism axis was renamed rather than the script axis because the script numbers are load-bearing in 46 places across board rows,
deferred.md, spy memory and territory briefings, while these stages are referenced in four and describe nothing that exists. "hooks v2" therefore always means the script revision.
Stage 2 — Lefthook
Once 3+ territories carry v1 hooks and one of:
- Pre-push latency routinely exceeds 3 minutes (the trigger for parallel execution),
- Adaptation drift becomes visible at the Adjutant sweep (the trigger for a single-config canonical),
- A new contributor onboarding loses meaningful time to "did you run the git config line?" (the trigger for
lefthook installergonomics).
Migration: lefthook.yml replaces .githooks/. Gates map 1:1. Per-territory adaptation surface stays (lefthook's files: and glob: patterns express scoped execution natively).
Stage 3 — Composer/npm-distributed package
Once cross-territory drift between v1 (or v2) hook scripts becomes a maintenance burden — at minimum a hook update lands in one territory and needs to be back-ported to N others by hand. Precedent: phpstan-warroom-rules as a Composer canonical.
Migration: composer require script-development/githooks (or npm equivalent); the package's post-install-cmd wires core.hooksPath automatically. Per-territory adaptation moves to a layered-override pattern.
Implementation
| Territory | State | Notes |
|---|---|---|
| (canonical template) | Complete (light) | /templates/githooks/ ships pre-commit, pre-push, README.md in the Amendment 2 light shape since 2026-09-29 (WR-1705), ported from lokalekeuze #766 plus LK-1163's lint-staged backup ref; adds script-name resolution with a distinct MISSING outcome. SIGPIPE protection not carried (open point). laravel-skeleton carries both scripts byte-identical. |
| war-room (this repo) | Complete | War-room-specific gates (not the composer/npm territory template). .githooks/ holds both pre-commit (debrief-filename convention: reports/*/debrief/* must end -debrief.md) and pre-push (VitePress build gate for presentation/**). Single core.hooksPath=.githooks fires both — re-applied idempotently by /sync. README at .githooks/README.md. Consolidated 2026-05-28 from a prior split where pre-commit lived under a top-level hooks/ and lost the single-valued core.hooksPath to .githooks/ every session. |
| isms | Complete | Adopted (.githooks/ with tracked pre-commit+pre-push; verified on disk 2026-07-11). First adopter (ISMS-0002). |
| brick-inventory-orchestrator | Complete | Adopted (tracked pre-commit+pre-push; verified 2026-07-11). |
| kendo | Complete | Adopted (tracked pre-commit+pre-push; verified 2026-07-11). Runs the light shape since KD-0981 (push-range-scoped pre-push); the model for Amendment 2. Its pre-commit formats backend/app/ only and re-stages whole files, and converges on the canonical's partially-staged rule. |
| codebook | Complete | Adopted (tracked pre-commit+pre-push; verified 2026-07-11). |
| town-crier | Complete | Adopted (tracked pre-commit+pre-push; verified 2026-07-11). |
| lokalekeuze | Complete (light) | Light shape adopted via #766 (LK-0833, merged 2026-09-24); the reference consumer for Amendment 2. |
| wijs | Complete | Adopted (tracked pre-commit+pre-push; verified 2026-07-11). |
| emmie | Complete | Canonical-settings fleet wave 2026-07-11 → PR #637 (merged 2026-07-13) (labeled, base development). Heavy script-name adaptation: added pint:check/rector:check aliases; PHPStan + Rector + integration = CI-only deviations; lint:pipeline (non-mutating) not lint. Both hooks validated e2e (pre-commit ~6s, pre-push 144.6s). |
| entreezuil | Complete | Canonical-settings fleet wave 2026-07-11 → PR #288 (merged 2026-07-11) (stacked on #287, a weekend-BranchFactory fix the new hook surfaced — merge #287 first). No composer aliases needed; Rector kept in pre-commit (fast here); vue-tsc → pre-push. Both hooks validated e2e. |
| ublgenie | Complete | Canonical-settings fleet wave 2026-07-11 → PR #467 (merged 2026-07-13). Root-monolith (wijs-shaped). Adaptations: Rector called direct (its rector:test pins --clear-cache = 72s/commit); oxlint via npx (dodges the in-flight #465 eslint---fix mutation); composer test on sqlite :memory:. pre-push adds Deptrac + Vitest. Both hooks validated e2e. |
| daymate-api | Not Started | Adopt opportunistically; Laravel 9 / PHP 8.2 — confirm hook scripts work against older composer/PHP. |
| fs-packages | Not Started | Single-root npm — hooks adapt via the canonical's single-root branch. |
| phpstan-warroom-rules | Not Started | Single-root composer — hooks adapt via the canonical's no-frontend branch. |
| the-laboratory | Not Started | Multi-app orchestrator; hook adoption per child app is the question — defer to a recon mission. |