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.
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 v2 | 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 v3 | 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 v2. - 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.
v2 — 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).
v3 — 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 | /templates/githooks/ ships pre-commit, pre-push, README.md. |
| 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 | Not Started | First adoption candidate (greenfield; ADR-0027 scaffold just landed). Engineer dispatch ISMS-0002 proposed. |
| brick-inventory-orchestrator | Not Started | Adopt on next Structural Reform touching tooling, or as a standalone Engineer mission. |
| kendo | Not Started | Adopt opportunistically. Will need adaptation for composer test:unit:coverage gate. |
| emmie | Not Started | Adopt opportunistically. Highest-compliance territory; hooks doctrine reinforces audit-trail discipline. |
| ublgenie | Not Started | Adopt opportunistically. |
| entreezuil | Not Started | Adopt opportunistically. |
| codebook | Not Started | Adopt opportunistically. |
| 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. |