Local Git hooks
Install Lefthook 2.1.14 or newer, then run make hooks and make hooks-check.
The configuration is tested with 2.1.14. Python 3.10 or newer, GNU Make, Git, a POSIX sh
and the repository Go version are required. The hooks find the interpreter and make
themselves, from fixed names, and prove each before they run it
(the interpreter the hooks run,
the make the hooks run).
Install yamllint, shellcheck, actionlint and hadolint
when editing their file types; applicable checks fail if their tool is missing
or older than its floor (linter version floors).
Strict source pushes also require gosec, govulncheck and semgrep. Golangci-lint runs
from the pinned tools/lint/go.mod tool directive when present, or from a matching directive
in tools/go/go.mod. The hook refuses Go pushes when neither module pins it; add a golangci-lint
tool directive to tools/lint/go.mod before pushing. Adoption does not currently create that pin.
Every golangci-lint run passes --allow-serial-runners: a lint that starts while another run on
the same machine holds golangci-lint's lock waits for it instead of failing the push (#855).
tools/go/go.mod pins gosec, govulncheck and gitleaks as tool directives, which Renovate
keeps current: make sec,
make vuln and make secrets run exactly those versions through go tool -modfile (make vuln
hands govulncheck to praetorctl security govuln, the
Go vulnerability gate), and
make secrets hands gitleaks to praetorctl security secrets reading merge commits via --log-opts="--full-history --branches --tags --remotes=origin -m" (the repository's own branches, tags and origin remote refs; history fetched from any other remote belongs to that repository's own scan).
In hosted CI, the secrets history scan runs as an emitted standalone gate:
go run -tags=secretsgate ./tools/secrets/gate
which executes the pinned gitleaks detector with --log-opts="--full-history --branches --tags --remotes=origin -m" (the repository's own branches, tags and origin remote refs; history fetched from any other remote belongs to that repository's own scan), reads HISS-12 exception entries from the SECRETS_EXCEPTIONS environment variable (a JSON list rendered by adopt from .standards.yaml), and judges findings with the shared rules package, exiting with status 3 on any finding not excused or any stale or expired entry.
go install -modfile=tools/go/go.mod golang.org/x/vuln/cmd/govulncheck github.com/securego/gosec/v2/cmd/gosec github.com/zricethezav/gitleaks/v8
puts the same versions on PATH for the hooks.
make verify-all also runs make semgrep-test, which checks the real matcher on
temporary positive, negative and boundary fixtures. The engine version comes from
.config/semgrep/requirements.txt; missing or mismatched engines fail the check.
This is rule regression coverage, not a replacement for repository source scans.
For an isolated installation, use the tested 1.178.0 release (Python 3.14 locally):
PRAETOR_TOOL_DIR="$HOME/.local/share/praetor-tools/semgrep-1.178.0"
python3 -m venv "$PRAETOR_TOOL_DIR"
"$PRAETOR_TOOL_DIR/bin/python" -m pip install -r .config/semgrep/requirements.txt
mkdir -p "$HOME/.local/bin"
ln -s "$PRAETOR_TOOL_DIR/bin/semgrep" "$HOME/.local/bin/semgrep"
semgrep --version
Keep $HOME/.local/bin on PATH. The symlink command deliberately refuses to
replace an existing executable. See the Semgrep package installation guidance.
On stop, checkpoint.py also judges the linked worktrees changed in the last 24 hours that the
stopping session touched (recorded per session from its tool calls; without a session id, every
recent worktree, and the finding says ownership is unknown). Gate scratch
(.standards/worktrees/gate-<pid>-<n> under any checkout of the repository) is skipped only
while that pid is a live praetorctl gate process, read from /proc/<pid>/cmdline; a reused or
unreadable pid fails closed. Abandoned gate scratch counts whichever session made it, and the
finding names the dead gate pid. Unfinished work in a judged worktree that
.workingdir/OPEN.md does not name keeps the stop due, and a branch whose change already landed
in the remote base counts as finished. The pull request state of the pushed branches comes from
one repository-wide listing, tried up to three times with a bounded backoff (1 s, then 2 s) inside
the worktree check budget; a listing that still fails keeps the stop due with one line naming the
lookup error and the count of pushed branches left unverified, instead of one finding per
worktree. The rules and the naming forms are in the
checkpoint cadence guide.
make verify-all also runs make hooks-lint (scripts/test_emitted_hook_lint.py). It lints
the Praetor-owned canonical hook sources: .config/lefthook/scripts/checkpoint.py and
common.py, which adoption copies into a repository, and .config/lefthook/praetor.yml, the
vendorable canonical policy. Adoption does not write praetor.yml; an adopter vendors it by
hand with its scripts and extends it from their own lefthook.yml, as
.config/lefthook/README.md describes. These files must pass the linters an adopter's own
hooks may run: black and yamllint with their built-in defaults (yamllint in strict mode) and
flake8 at 100 columns. The gate lints copies in an empty directory with configuration files
ignored, and checks negative and boundary fixtures so the policy is proven to be on.
The same run covers every managed asset adoption writes, enumerated from the registry by
go run ./internal/managedasset/export and not by a list kept in the script: the documentation
gate's workflow and YAML, the figure engine's Python and the API compatibility gate's Go program.
Audit holds an adopter's copies byte-equal, so an adopter cannot fix a finding in them. They are
held to black, flake8, yamllint, ruff check and ruff format --check (Python), gofmt, gofumpt
and go vet (Go) and shellcheck (shell); the policy is in the script's docstring and in the
figures guide.
The gate also lints the two hook files adoption renders from templates in
internal/adopt/hooks.go: the root lefthook.yml (buildLefthookYAMLFor) and
.config/agent/hooks/block_evasion.py (blockEvasionTemplate). Their renderings are
committed under internal/adopt/testdata/emitted, at the paths adoption writes them, and
TestEmittedHookFixturesMatchTheRendering fails when a fixture differs from the rendering.
After a template change, regenerate them with
PRAETOR_UPDATE_EMITTED_FIXTURES=1 go test ./internal/adopt -run TestEmittedHookFixturesMatchTheRendering.
The rendered lefthook.yml opens with a document start and folds every run line longer than
80 columns into a >- block, which YAML reads back as the same one-line command
(TestLefthookRendering_Positive_FoldsWithoutChangingValues). The rendered interceptor splits
long rule patterns and refusal texts into adjacent string literals laid out as black leaves
them (internal/adopt/emitted_layout.go), and every function stays within the 35 lines of
the strictest public dogfood policy (TestVendoredCheckpointSourcesFitStrictAdopterLOC).
A lefthook.yml an earlier release rendered with unfolded lines is still recognised and
migrated on the next adoption (priorLefthookDigests in
internal/adopt/lefthook_identity.go).
The tools come from the hash-locked .config/hook-lint/requirements.txt, which Renovate
recompiles from requirements.in:
python3 -m venv "$HOME/.local/share/praetor-tools/hook-lint"
"$HOME/.local/share/praetor-tools/hook-lint/bin/python" -m pip install --require-hashes -r .config/hook-lint/requirements.txt
PRAETOR_HOOK_LINT_BIN="$HOME/.local/share/praetor-tools/hook-lint/bin" make hooks-lint
gofumpt is pinned in tools/go/go.mod instead, the one version source for Go tools; install it
into the same directory with
GOBIN="$HOME/.local/share/praetor-tools/hook-lint/bin" go install -modfile=tools/go/go.mod mvdan.cc/gofumpt.
go, gofmt and shellcheck come from PATH.
Without PRAETOR_HOOK_LINT_BIN the gate takes the tools from PATH, and each check skips
with its reason when its tool is missing or is not the pinned version. With the variable set,
as CI sets it, a missing or mismatched tool fails the gate.
make hooks-lint also runs scripts/test_emitted_yaml_lint.py, which reuses that gate's tool
resolution to run yamllint --strict -d default over the other YAML Praetor writes into a
repository:
.config/labels.yaml, linted as praetor's own copy, whichinternal/forge/labels_test.goholds byte-equal toforge.DefaultLabelTaxonomy;- every profile and facet under
.config/archetypes, which adoption copies byte for byte into the adopter's pinned catalog; - the
.standards.yamlrendering (config.RenderManifest, which adoption,praetorctl initand onboarding share), frominternal/adopt/testdata/emitted/.standards.yaml, whichTestEmittedHookFixturesMatchTheRenderingkeeps equal to the rendering of the declarations ininternal/adopt/testdata/manifest/prior.standards.yaml; - every
.ymland.yamlbody undertemplates/that flavor apply scaffolds, and every templateinternal/flavor/definitions.gochecks with a YAML validator whatever its name (.clang-formatand.clang-tidy, which the Visual Studio editor target also writes), with its leading template comment dropped as rendering drops it (test_every_yaml_validated_flavor_template_is_linted). A body whose actions branch on the repository (the Node CI job per package manager, the Dart analyzer config per lint package) is linted from its committed renderings undertemplates/testdata/rendered/, one per distinct body.TestBranchingYAMLTemplateRenderingsAreCommitted(templates/branching_test.go) renders every combination of the facts the template reads and keeps those files equal to the result.
Each path comes from the Go constant that names it. The gate also appends an overlong line to every file and requires yamllint to reject each one, so the rule is proven to be on.
| Git stage | Work performed |
|---|---|
pre-commit, pre-merge-commit |
Audit the live private ledger, then check the exact index for whitespace, conflict markers, Python/JSON syntax, YAML, shell, workflow and Docker lint; Go formatting and vet on changed packages; verify affected generated agent instructions. |
prepare-commit-msg |
Add an instructional comment to a fresh empty message. |
commit-msg |
Verify the live state synchronization after Lefthook restores partially staged worktree files, then apply the commit message policy (praetorctl forge check-message: a conventional subject, Git merge/revert subjects accepted, the HISS-14 Migration: footer on a breaking change, and an offline warning for ambiguous closing references; explicit pull request URLs require Closes-own: <reference>) and require a DCO sign-off. |
pre-push |
Audit and verify live state before inspecting each actual pushed commit. Run praetorctl forge refactor-check --base=<base> --head=<head> over the pushed range for the REF-02 assertion lock; a push without a known base skips it with that reason, and CI checks the pull request range. checkpoint/* destinations run file checks, affected Go builds and race tests. Other destinations also require lint, security, vulnerability, governance, flavor and signed-receipt gates. The signed gate runs Go race tests only for the affected packages and reverse dependents; an empty selection records not_applicable: no affected Go package. |
post-commit |
Synchronize and read back the private state for the new commit, then print the dedupe cadence reminder when due. Sync failures are reported. |
post-checkout, post-merge, post-rewrite |
Warm changed module dependencies in an isolated clone, rebuild the local CLI for source changes, verify affected agent outputs, report governance changes. post-merge then runs praetorctl workstation install --if-stale, which rebuilds a lagging engine install from this checkout on the update branch and otherwise skips silently (refresh a lagging install). File-only checkouts do nothing. |
pre-rebase |
Check the hook environment before replay begins. |
Pre-commit exports the index into a temporary directory. Unstaged edits, untracked
files and the index remain unchanged. Formatting is a read-only gate: run gofmt
and stage your chosen hunks explicitly. The export pins core.autocrlf=false, so
the isolated checks inspect the bytes in the index instead of rewriting them to the
operator checkout's line endings. Deleted files are included when computing
scope and skipped by per-file linters. Paths are read with NUL delimiters and
passed as process arguments, including filenames containing spaces or shell text.
A missing required tool or a failed subprocess blocks the operation.
yamllint covers every staged .yml and .yaml file except a Helm chart template: a file
at any depth under a templates/ directory whose chart root holds Chart.yaml. Helm renders
templates/ recursively, so <chart>/templates/rbac/role.yaml is as much a template as
<chart>/templates/service.yaml. Those files are Go templates that render YAML, so a YAML parser
rejects {{- if }} before a single rule can run. The chart's own Chart.yaml and
values.yaml are ordinary documents and stay linted, and a templates/ directory with no
Chart.yaml beside it is not a chart at all.
A staged change to lefthook.yml, a native client hook file (.claude/settings.json,
.codex/hooks.json, .gemini/settings.json), the AGY plugin's hooks.json or guard copy
under .agents/plugins/praetor/, anything under .config/lefthook/ or .config/agent/, or
one of the two hook test scripts runs the harness self-tests
(.config/lefthook/scripts/checks.py): lefthook validate, test_hooks.py,
test_security_scope.py, test_checkpoint.py, scripts/test_checkpoint_hooks.py, and
scripts/test_praetor_hook.py. The last one runs the tracked agent-hook strings through
sh -c against the skew guard .config/agent/hooks/praetor_hook.py and its AGY plugin copy
(Agent hooks). make hooks-test
runs the same scripts.
The entire /.workingdir/ directory is private, Git-ignored workstation state.
Git metadata checks reject staged additions and changes beneath it, including
forced staging and submodule entries, before exporting the index. Push checks
inspect every outgoing commit, so adding a private file and removing it in a later
commit still blocks publication. The history scan is bounded to 1,000 commits;
an unknown baseline selects the full reachable history, and exhaustion fails.
Removing previously tracked entries is allowed and preserves local
files when done with git rm --cached. Previously published content remains in Git
history. Put cluster connection guides and backend notes in this ignored directory;
publish only reviewed, sanitized documentation under docs/.
The root .dockerignore also excludes this directory and Git history from local
container builds; Docker applies its build-context ignore rules
separately from Git.
Linter version floors
shellcheck, actionlint, hadolint and yamllint run by name from PATH, so the hook
checks each one before it trusts its verdict. .config/lefthook/tool-floors.txt declares
them, one line per linter in the shape of the requirements files beside it:
| Line | The hook requires |
|---|---|
tool>=version |
the linter, at that version or newer |
tool |
the linter, at any version |
A floor needs a source in this repository, and each line's comment names it: the
actionlint release .github/actionlint.yaml measures against, the shellcheck release
.github/workflows/portability.yml installs and the yamllint pin in
.config/hook-lint/requirements.in. hadolint has no floor. No workflow, container image
or configuration file here installs it or names a version of it, and a version taken from
one workstation would refuse contributors on another without evidence that the verdict
on the root Dockerfile differs.
Before any linter runs, file_checks (.config/lefthook/scripts/checks.py) passes the
linters with files to check to require_floors (.config/lefthook/scripts/toolchain.py),
which reads each floored one's --version in one process bounded to 30 seconds. The commit
or push is refused, with the requirement and the floors file named, when a linter:
- is not installed or does not start;
- prints no version the policy reads, such as an
actionlintbuilt from an untagged checkout, which prints(devel); - is older than its floor.
A newer release passes: a floor is the oldest version the tree's verdict is known to hold
with, not a pin. A linter is probed only when a file of its type is staged or pushed, so a
missing hadolint blocks a commit that stages a Dockerfile and no other. A linter the hook
runs without a line in the floors file fails the same way, as does a floors file that is
unreadable or holds a line in any other shape.
praetor hooks: shellcheck >= 0.11.0 is required (.config/lefthook/tool-floors.txt): the shellcheck on PATH is 0.9.0
praetor hooks: hadolint is required (.config/lefthook/tool-floors.txt, any version): it is not on PATH
The ToolFloors cases in .config/lefthook/scripts/test_hooks.py cover each outcome and
replay the version readers against the linters installed on the host. The
HookToolFloorSources cases in scripts/test_portability_selftest.py hold each floor to its
source: they fail when the yamllint pin or the shellcheck release falls below its floor,
when .github/actionlint.yaml measures against another actionlint release than the floor,
and when a workflow or the development container starts to name hadolint while its line
still declares no floor. To raise a floor, change its line together with the source its
comment names.
One reader serves every requirement line (requirement in toolchain.py): the floors file
and the hash-locked lint lock that scripts/test_emitted_hook_lint.py installs from are
parsed by it, and stated_version is the one version probe behind the linters, the
interpreter and make.
The interpreter the hooks run
No hook command names a Python interpreter, and no environment variable selects one. Every
job in .config/lefthook/praetor.yml and the pre-push script start their hook through
.config/lefthook/python.sh, which tries a fixed list of candidates in order and runs the
first that proves itself:
| Candidate | Where it is the interpreter |
|---|---|
python3 |
the name Linux and macOS give Python 3 |
python |
the command an install on Windows provides |
py -3 |
the py launcher an install on Windows provides, asked for Python 3 |
The Windows commands are the ones Using Python on Windows lists.
A candidate proves itself by running a one-line program that prints its version, and the
launcher accepts it only when the answer is Python 3 at 3.10 or newer, the release
.config/hook-lint/requirements.txt is compiled for. Finding a file of that name is not
proof. Windows 10 and 11 put python3.exe and python.exe on the user's PATH as App
Execution Aliases for the Microsoft Store: every PATH lookup finds them, and started with
arguments they print Python was not found and exit nonzero. Such a candidate, a program
that exits 0 without answering and a Python older than the floor are skipped for the next
one. The proven candidate then replaces the launcher's shell, so the status it returns is
the hook's verdict and nothing else's.
With no candidate left the hook fails with status 127 and names what it tried:
praetor hooks: missing dependency: no Python 3.10 or newer interpreter.
Tried: python3, python, py -3; none answered the version probe.
Install Python 3.10 or newer under one of these names.
A variable that named the program a hook starts would turn every gate into a pass once it
named a program that exits 0, so none is read. test_no_variable_selects_the_interpreter
and test_no_variable_takes_a_hook_job_off_its_interpreter_or_its_make set such variables
and require the hook to judge as before.
A hook that starts another Python process, such as the harness self-tests or the command
guard, reuses the interpreter it is running under (sys.executable), so a second
interpreter can never run beside the first.
The candidate list exists once per language that needs it and tests hold the copies equal:
| Where | Used by | Held equal by |
|---|---|---|
.config/lefthook/python.sh |
every Lefthook job, before any interpreter runs | the two tests below |
PYTHON_CANDIDATES, PYTHON_FLOOR and PYTHON_PROBE in .config/lefthook/scripts/toolchain.py |
the report below and scripts/dev_install.py |
test_the_launcher_and_the_policy_declare_one_candidate_list, and test_candidates_are_tried_in_order_and_held_to_the_floor, which runs both on the same stand-in programs |
the hooks.python default in internal/config/operator_sections.go |
the Go checkpoint evaluator (checkpoint cadence) | TestHookLauncherCandidatesAreTheOperatorDefault in internal/forge/hook_toolchain_guard_test.go |
scripts/dev_install.py resolves the same list before it installs anything
(hook_interpreter, which calls python_program in toolchain.py). It reports the command
and version the hooks will start under hook_interpreter in its install report, stops when
no candidate proves itself, and stores nothing in the environment.
Every line of the launcher ends in a comment sign. praetorctl adopt copies the file into
other repositories (the launcher adoption writes), where a
checkout under core.autocrlf may give it CRLF line endings, and a shell reads a carriage
return as part of the last word of a line; behind a comment sign it is part of the comment
(test_a_crlf_checkout_of_the_launcher_runs_the_hook).
To see what a host provides, run the declared-toolchain report. It resolves the interpreter
and make from their candidates, looks up each program the hooks start by name and reads
each floored linter's version:
python3 -B .config/lefthook/scripts/toolchain.py
python3 -B .config/lefthook/scripts/toolchain.py --require python,make,sh,shellcheck,yamllint
Each declared tool gets one line. Without --require the run always exits 0 and a tool that
is missing is reported as not asserted with the message a hook would give; with it, a named
tool that is unusable is reported as UNUSABLE and the run exits 1. The Platform Neutrality
job runs the second form on every leg before the harness self-tests
(HISS-21).
The HookInterpreter cases in .config/lefthook/scripts/test_hooks.py cover the launcher,
and test_a_hook_job_refuses_stand_ins_for_its_interpreter_and_its_make drives a real
Lefthook job with stand-in programs first on PATH. The agent-client registrations
(.claude/settings.json, .codex/hooks.json, .gemini/settings.json) still name python3
themselves; see agent hooks.
The launcher adoption writes
The lefthook.yml that praetorctl adopt writes starts its two checkpoint jobs through the
same launcher. Adoption copies .config/lefthook/python.sh from the --lock-source-root
bundle with the checkpoint scripts (checkpointBundle in internal/adopt/checkpoint.go), and
each job runs sh .config/lefthook/python.sh -B .config/lefthook/scripts/checkpoint.py ...
(lefthookPythonCommand in internal/adopt/hooks.go).
The generated line holds a script path and plain arguments, with no quote and no expansion.
Lefthook's Windows executor hands a run line to sh -c inside one pair of double quotes
without escaping the quotes in it, so a rule stated inline with a quoted variable would lose
its quotes there and split an interpreter path that holds a space.
TestLefthookPythonCommandStartsTheBundledLauncher refuses any such character in the line,
and TestLefthookPythonCommandRunsTheHookThroughTheLauncher runs it through sh, also with
a CRLF copy of the launcher.
A lefthook.yml from an earlier release, whose checkpoint jobs named python3, is migrated
on the next adoption from a source bundle, and the launcher is installed in the same run
(TestAdopt_Boundary_Python3ByNameRenderingMigratesToTheLauncher). A source root without the
launcher installs no part of the bundle and enables no checkpoint job
(TestCheckpointLauncher_Negative_SourceWithoutItInstallsNothing).
An unedited checkpoint.py from an earlier release is refreshed on the next adoption, because
every text a release shipped is recorded with its digest (priorCheckpointDigests in
internal/adopt/checkpoint.go, reproduced by internal/adopt/testdata/checkpoint). A change
to the script records its new digest in the same change. The current script judges a re-run of
a hosted check by its latest run on the head, and on a draft reads a hosted gate's failure that
carries exactly the draft annotation as draft_pending rather than failed, and defers inside a
detached job that has neither a push transport nor a forge token;
checkpoint cadence states these rules.
The engine the hooks run
Every governance job of the generated lefthook.yml and the fallback pre-commit hook run
sh .config/lefthook/engine.sh <arguments> instead of a praetorctl found on PATH
(lefthookGovernedCommand in internal/adopt/hooks.go; the script is
engineLauncherScript in internal/adopt/engine_launcher_script.go, installed by praetorctl adopt).
A newer binary on PATH therefore never judges a repository that pins an older engine: the hook runs the engine
the repository declares, the one its hosted Standards job installs.
The pin is the value of PRAETOR_REF declared in any file under .github/workflows, as
PRAETOR_REF: <ref> or PRAETOR_REF=<ref>, a PRAETOR_COMMIT value that is exactly 40
lowercase hexadecimal characters, or the uses: reference of praetor-adopt
(uses: cordanaLLM/praetor/.github/actions/praetor-adopt@<ref>) or a reusable workflow under
cordanaLLM/praetor/.github/workflows/ (quotes, a trailing comment and CRLF line endings
are read past). A ref is a commit id of 7 to 40 hexadecimal digits or a full release tag such as
v1.2.3 (a tag without patch digits like v1 or v1.2 moves under go install, and a branch
name moves, so they are no pin). A non-pin uses: ref such as @main is treated as unpinned (running
the engine on PATH). A PRAETOR_COMMIT value that is not a full lowercase commit is named in a
note and ignored. Declaring two different pin values across PRAETOR_REF and PRAETOR_COMMIT
fails closed; declaring the same value through both names is one pin.
The cache is ${XDG_CACHE_HOME:-$HOME/.cache}/praetor/engine/<pin>, one directory per pin.
The first hook run with a pin that is not cached installs it:
The install goes through a scratch directory whose files are renamed into place, so an interrupted run leaves no
half-written engine and parallel jobs on a cold cache cannot remove each other's engine
(TestEngineLauncher_Boundary_ConcurrentColdInstalls), and it is bounded by PRAETOR_ENGINE_INSTALL_TIMEOUT seconds (default
300, must be an integer >= 1). Later runs find the cached binary and install nothing. Changing the pin selects, and
installs when needed, another directory; old directories stay until you delete them.
Without a pin the hook runs the binary on PATH, praetorctl before standardsctl, as
before, and prints praetor hooks: no PRAETOR_REF pin or full PRAETOR_COMMIT pin under
.github/workflows; running the engine on PATH: <path> on standard error.
It never falls back. A pin that is unusable, declared twice with different values, an unreadable workflow file, or an install that cannot complete (no Go toolchain, no network, a timeout) fails the hook with one message that names the pin, the cache directory and the command that fixes it, for example:
praetor hooks: installing the engine pinned by PRAETOR_REF=492a00f930e1 (declared in .github/workflows/standards-gate.yml), engine cache /home/me/.cache/praetor/engine/492a00f930e1 failed (exit 1, timeout 300 seconds); run: GOBIN=/home/me/.cache/praetor/engine/492a00f930e1 go install github.com/cordanaLLM/praetor/cmd/standardsctl@492a00f930e1
The launcher is a POSIX shell script that runs under Git Bash on Windows, and every line ends in a comment sign for the reason given for the Python launcher.
Generated Makefiles and adopted Makefiles include .config/praetor/engine.mk when the hook launcher is installed or planned,
a Praetor-managed file that resolves $(PRAETORCTL) through .config/lefthook/engine.sh
and provides the praetor-engine-path target, aligning Make targets and hooks with the repository's pinned
engine (#906, HISS-19). Adoption installs the launcher and engine.mk (and offers the include) independently
of whether it writes lefthook.yml: when a repository keeps its own lefthook.yml, adoption leaves that
configuration untouched and unactivated, installs the launcher and engine.mk, and reports that the kept
hook file does not call the launcher yet, naming the command prefix sh .config/lefthook/engine.sh <args> to use (#1172).
To avoid becoming Make's default goal when included before the first target,
engine.mk guards praetor-engine-path by saving $(.DEFAULT_GOAL) before the rule and restoring it afterward.
For an adopter-owned Makefile, adoption inserts
-include .config/praetor/engine.mk near the top (after leading comments), leaving existing rules in place.
Because the first ?= assignment wins in Make, any later fallback assignment becomes a
no-op without needing to recognize or edit existing verification blocks. When engine.mk is installed or planned,
an adopter's assignment using :=, ::=, =, !=, +=, or define is respected and reported as a warning naming the line,
and an assignment using ?= is shadowed by .config/praetor/engine.mk and is reported as a warning advising := or = to override.
Where engine.mk is neither installed nor planned (such as when git-hooks is declined), neither warning is emitted.
One pin reader. .config/lefthook/engine.sh is also a standalone entry point: an adopter
Makefile target or script runs sh .config/lefthook/engine.sh <arguments> from the repository
root to get the same pin resolution, cache and refusals as the hooks, and needs no second reader
of PRAETOR_REF and PRAETOR_COMMIT. When a recipe or rule needs the binary path directly, --print-path prints
only the path of the resolved binary, which .config/praetor/engine.mk uses:
Tests (internal/adopt/engine_launcher_test.go and internal/adopt/engine_makefile_test.go): TestEngineLauncher_Positive_PinnedEngineWinsOverPath
runs stub binaries that print their identity with a different praetorctl first on PATH;
TestEngineLauncher_Positive_InstallsOncePerPin, TestEngineLauncher_Negative_UninstallablePinFailsClosed,
TestEngineLauncher_Negative_NoGoToolchainFailsClosed, TestEngineLauncher_Boundary_InstallTimeout,
TestEngineLauncher_Negative_UnusablePinsFailClosed, TestEngineLauncher_Negative_ConflictingPinsFailClosed,
TestEngineLauncher_Positive_UsesRefPinsEngine, TestEngineLauncher_Boundary_NonPinUsesRefRunsOnPath,
TestEngineLauncher_Negative_UnreadableWorkflowFailsClosed, TestEngineLauncher_Positive_PrintPath,
TestEngineLauncher_Negative_PrintPathFailsClosedOnUnusablePin, TestEngineLauncher_Boundary_InstallTimeoutZeroFailsClosed
and TestEngineLauncher_Boundary_NoSleepSurvivesInstall cover the cache, the pin sources, --print-path and the refusals;
TestEngineLauncher_Boundary_NoPinKeepsPathBehaviourAndSaysSo the unpinned case;
TestEngineMakefile_Positive_KeptCustomLefthookInstallsLauncherAndEngineMk,
TestEngineMakefile_Positive_KeptCustomLefthookCallingLauncherOmitsPrefixHint,
TestEngineMakefile_Negative_KeptCustomLefthookWithDeclinedGitHooksGetsNeitherFile,
TestEngineMakefile_Boundary_KeptCustomLefthookKeepsEditedLauncherWithWarning,
TestEngineMakefile_Boundary_KeptCustomLefthookCommentOnlyMentionGetsPrefixHint and
TestEngineMakefile_Positive_OverrideWarningPresentWithEngineMkAbsentWithout cover kept hook configurations and conditional override warnings (#1172).
The make the hooks run
The hooks need GNU Make on every platform. pre-commit, commit-msg, pre-push and the
post-* hooks build the repository's CLI through the hook-cli Make target before they run
it (cli, refresh and refresh_install in .config/lefthook/scripts/hooks.py), and a
strict push runs the state-audit target (source_checks in checks.py). They pass
--always-make and --no-print-directory, which are GNU Make options.
All four calls take the program from make_program (.config/lefthook/scripts/toolchain.py).
It tries make, then gmake, then mingw32-make, skipping a name that is not on PATH,
and uses the first whose --version output begins with GNU Make. gmake is the name GNU
Make has where make is another make, as on the BSDs; mingw32-make is its name on a MinGW
host. A candidate that exits 0 without stating GNU Make is skipped like one that fails, and
no environment variable selects the program, for the reason given for the interpreter.
Before the CLI is built, pre-commit has already run the environment guard, read the staged
paths and checked the index for private state. When no candidate proves itself, the hook
stops at that point, before the CLI is built and before any check that needs it:
praetor hooks: missing dependency: GNU Make, which builds the CLI the hooks run (the hook-cli target). Tried: make (not on PATH); gmake (not on PATH); mingw32-make (not on PATH)
Nothing is skipped in that case. A hook that cannot build the CLI would run a stale binary
or none, so a missing make refuses the commit or push and says what was tried.
The declared-toolchain report above prints the GNU Make version and the path the hooks would
run. The Platform Neutrality job requires it on every leg before the self-tests; no leg
installs it, each uses the one its image carries
(HISS-21).
The HookMake cases in .config/lefthook/scripts/test_hooks.py cover the resolution, and
the Lefthook job test named above puts stand-ins for all three names first on PATH.
The lefthook.yml adoption writes
praetorctl adopt writes a root lefthook.yml (buildLefthookYAMLFor in
internal/adopt/hooks.go). Its governance jobs are the same in every repository:
compile-context --verify and audit --offline before a commit, state sync and the dedupe
cadence after one, and flavor audit, audit and gate run before a push (see
Offline before a commit, online before a push).
The Rust jobs follow the languages the adoption verification plan detects from root markers. Go
jobs are always scaffolded, and discover tracked modules when the hook runs, so adding a nested
module does not require re-adoption:
| Detected | Pre-commit jobs | Pre-push jobs | Each runs only where |
|---|---|---|---|
Tracked Go modules (go.mod at any discoverable depth) |
gofmt (gofmt -w on staged files), govet (sh .config/lefthook/engine.sh go vet, one go vet ./... per tracked module) |
security (sh .config/lefthook/engine.sh security govuln, one symbol-level scan per tracked module; skipped when govulncheck is not installed) |
Go files are staged for gofmt/govet; the engine discovers modules at hook time |
Rust (Cargo.toml) |
rustfmt (cargo fmt --all --check), clippy (the gate's own cargo clippy --workspace --all-targets -- -D warnings, gating.CargoClippyArgs) |
none: gate run runs cargo audit for a Cargo.lock |
Rust files are staged and the root holds Cargo.toml |
| no Rust marker | the Go jobs plus governance jobs | the Go security job plus governance jobs | Go jobs statefully skip when Git tracks no discoverable module |
Any other detected language, such as Python, gets the Go and governance jobs; Go discovery happens
when the hook runs rather than when adoption runs. The header comment names the language jobs the
file carries. Tests:
TestAdopt_Positive_LefthookJobsFollowDetectedLanguages and
TestAdopt_Boundary_LefthookJobsForUnknownAndJoblessLanguages in
internal/adopt/lefthook_languages_test.go, and the TestCargoJobs_* and TestGoModuleJobs_*
cases in internal/adopt/hooks_gomod_test.go, which run the job lines against stub tools.
A root that carries REUSE.toml or .reuse/dep5, in a repository that does not decline
reuse-gate, also gets a reuse-lint pre-commit
job (reuseLintCommand): reuse lint with the reuse major that fsfe/reuse-action@v6 runs in
the hosted REUSE gate adoption writes beside it
(what adoption scaffolds). It skips,
saying why, where reuse is not installed or the root has lost both markers, and fails, naming the
pin, on another reuse major. A lefthook.yml adoption wrote before the root gained or lost the
markers is migrated without --force, keeping its checkpoint jobs.
TestReuseLintCommand_RunsTheLintAtThePinnedMajor in internal/adopt/reuse_gate_test.go runs
the line against a stub reuse, and TestReuseLintCommand_Negative_UnlabelledFileFailsTheJob
against the installed one.
The pre-push gate job runs gate run --path=. --admit-unsupported (prePushGateArgs). A root
with neither go.mod nor Cargo.lock, such as a Meson, CMake, npm or Python repository, gets no
receipt, because the gate runs no toolchain for it: the job runs every other stage, names the
languages it could not verify and admits the push instead of refusing every one. Where such a root's
makefile declares verify-all, the job runs it first, and a failing verify-all refuses the push
(A declared verify-all runs where no toolchain stage applies).
A root with either marker still fails the job when its toolchain stages did not run
(The pre-push hook admits languages the gate has no runner for).
TestLefthookGate_Positive_PrePushGateAdmitsUnsupportedLanguages in
internal/adopt/lefthook_gate_admit_test.go runs the job line against a stub.
The rust-systems and rust-library flavors describe this setting as pre-commit clippy and
rustfmt enforcement, and flavor audit holds it to that: lefthook.yml satisfies it only when its pre-commit run
lines call cargo fmt (or rustfmt) and cargo clippy, in the commands map or a jobs list
(validRustLefthook in internal/flavor/lefthook_setting.go, tests in
internal/flavor/lefthook_setting_test.go). The other flavors accept any non-empty mapping.
An existing lefthook.yml is classified before anything is installed (classifyLefthookConfig in
internal/adopt/lefthook_identity.go):
- A current rendering for the repository's languages is verified and activated. A CRLF
checkout of one is verified and kept without
--force, and rewritten with its LF bytes under--force, since activation trusts only those. The rendering without checkpoint jobs gains them once the checkpoint lifecycle is installed; the one with them is kept by a run that did not install the lifecycle (internal/adopt/lefthook_keep_test.go). - An earlier rendering listed in
priorLefthookDigests, reproduced byinternal/adopt/testdata/lefthook/, is migrated to the current one on a plain run and activated. The list holds the two renderings that carried the Go jobs into every repository, so those adopters get the jobs of their own languages without--force(TestAdopt_Negative_GoEveryRepositoryRenderingMigratesToCargoJobs). It also holds the renderings whose pre-commit audit still read the forge, one per language set with and without checkpoint jobs, so those adopters getaudit --offlinebefore a commit on a plain run (TestAdopt_Boundary_OnlinePreCommitAuditMigratesWithoutForce), and the renderings whosegatejob ran without--admit-unsupported, so a Meson repository's pushes stop being refused on a plain run (TestAdopt_Boundary_StrictPrePushGateMigratesWithoutForce), and the Go renderings whosesecurityjob rangovulncheck ./..., so a Go repository gets the vulnerability gate on a plain run (TestAdopt_Boundary_PlainGovulncheckMigratesWithoutForce). - Any other file is the repository's. It is kept byte for byte,
--forceincluded, and not activated: the audit checks only thatlefthook.ymlexists, so--forcehas nothing to restore. The skip names the generated jobs the file lacks and the jobs it adds, or says the file does not parse as a YAML mapping. A file that extends.config/lefthook/praetor.ymlis reported as the canonical policy instead. Beside a kept file adoption writes only the checkpoint files that are absent and refreshes none (TestAdopt_Negative_ForeignLefthookKeptWithAndWithoutForce,TestAdopt_Positive_ForceKeepsAdopterExtendsLefthook,TestAdopt_Boundary_UnparsableLefthookKeptAbsentCreated,TestAdopt_Positive_KeptLefthookGetsOnlyAbsentCheckpointFiles).
To regenerate a kept file, remove lefthook.yml and re-run adopt, or merge the named jobs by
hand and run lefthook install. After changing the template, record the rendering it replaced:
copy it under internal/adopt/testdata/lefthook/ and add its digest to priorLefthookDigests
(TestPriorLefthookDigests_Positive_ReproducedByFixtures).
Offline before a commit, online before a push
The two audit jobs differ on purpose (preCommitAuditArgs and prePushAuditArgs in
internal/adopt/hooks.go):
| Job | Command | Reads the forge |
|---|---|---|
pre-commit hiss-audit, and the fallback pre-commit hook |
praetorctl audit --offline |
never |
pre-push audit |
praetorctl audit |
when the live Actions checks can run |
A commit reads files only, so it never waits on the network and never fails on a forge
setting. A push runs the live Actions checks when the origin remote names the manifest's
repository on github.com and a token is found. That costs up to 130 GitHub API requests: two
for the workflow permissions, and one or two for each of at most 64 workflows. Each request is
bounded at 15 seconds and all of them together at three minutes. Without GITHUB_TOKEN or
GH_TOKEN the audit also runs gh auth token once. A push whose live workflow permissions
have drifted from overrides.actions is refused until the setting the verdict names is fixed.
make verify-all and CI run the online audit too; whether it reaches the forge there depends
on the token they provide. Tests: TestLefthookAudit_Positive_PreCommitOfflinePrePushOnline
in internal/adopt/lefthook_audit_offline_test.go.
The hook runner the audit accepts
Unless .standards.yaml lists git-hooks in adoption.decline, praetorctl audit requires
two things in a Git checkout: a hook runner's configuration, and a pre-commit hook that this
runner installed. Both checks live in internal/adopt/hook_runner_audit.go:
AuditGitHookConfigchecks the configuration.praetorctl auditandstandards_auditboth run it. It acceptslefthook.yml. Withoutlefthook.yml, it accepts a.pre-commit-config.yamlthat runs both praetor commands (see below).AuditInstalledGitHookchecks the installed hook. Onlypraetorctl auditruns it, and not in CI, which installs no hooks. In CI the audit prints the runner and the configuration file it verified instead.
Adoption writes lefthook.yml and never a pre-commit framework configuration. The hook is the one
in the managed hooks directory, <git-common-dir>/hooks, which every linked worktree shares.
core.hooksPath moves every hook git runs, so a value that leaves this directory skips the
hooks installed for the repository: /dev/null skips them all, and a directory elsewhere runs
whatever it holds. The audit therefore fails when core.hooksPath is set at any scope git reads
for the repository (system, global, local, worktree, and the git -c values that
GIT_CONFIG_PARAMETERS or GIT_CONFIG_COUNT hand the audit's own process) to a value that does
not name the managed hooks directory, even when that
other directory holds a known runner's hook. A relative value is resolved from the working tree
root, as git resolves it. An existing directory is compared by filesystem identity. A directory
not yet created is compared by its spelling, after the symlinks in its existing parent directories
are resolved on both sides. A checkout reached through a symlink, such as macOS's
/var -> /private/var, therefore passes with .git/hooks before that directory exists. A value
with a .. element passes only when it names the existing managed directory, because the
operating system applies .. after any symlink before it. The failure names each such value,
its scope and the file that sets it, and the command that removes it:
- a value in the file that
git config --<scope>edits:git config --unset-all --<scope> core.hooksPath; - a value in another file, one that an
include.pathorincludeIf.<condition>.pathnames, or$XDG_CONFIG_HOME/git/configwhile~/.gitconfigexists: that file, andgit config --file "<file>" --unset-all core.hooksPath. The--<scope>form edits one file only,~/.gitconfigfor--globalwhen it exists, and cannot remove the value; - a
git -cvalue: the option or theGIT_CONFIG_PARAMETERSorGIT_CONFIG_COUNTvariable that passes it.
Where lefthook.yml exists and a local or global value from the file git config --<scope>
edits is refused, the failure also names lefthook install --reset-hooks-path. Lefthook 2.1.14 then unsets the
local and the global value (unsetHooksPathConfig in its
install command)
and installs the hooks.
The rule is auditHooksPath in internal/adopt/hooks_path_audit.go. Adoption runs the same
rule before it installs a hook. On such a value it reports the audit's finding, fix included, as
an error, and installs no hook, in a dry run too. It no longer installs the hook in the directory
core.hooksPath names, where the next audit refused it. When git cannot read a value at all, for
example a ~user path for a user that does not exist, adoption reports that read failure
instead, and installs no hook either. No supported runner installs anywhere
else: Lefthook 2.1.14 refuses to install while core.hooksPath is set globally, or locally to
anything but .git/hooks, and the pre-commit framework 4.6.2 refuses while it is set at all.
The audit reads the configuration that is in effect when it runs. A commit made with a one-off
git -c core.hooksPath=... override leaves no configuration behind, so CI re-runs the commit
checks over every commit of a pull request (commit checks CI re-runs).
The audit recognises a hook by the marker its runner uses to recognise its own file:
| Runner | Marker | Configuration the runner reads |
|---|---|---|
| Lefthook | the LEFTHOOK fingerprint Lefthook 2.1.14 checks for (isLefthookFile), together with its template's last line, call_lefthook run "pre-commit" |
lefthook.yml |
| Praetor's fallback hook, which adoption writes when lefthook cannot install | # praetor-managed pre-commit hook (fallbackPreCommitMarker in internal/adopt/hooks.go) |
lefthook.yml |
| The pre-commit framework | an ID the framework counts as its own hook's: the # ID: line pre-commit 4.6.2 writes, or an earlier template's (is_our_script in pre_commit/commands/install_uninstall.py) |
.pre-commit-config.yaml, which must be the file the hook passes as --config |
The hook must also be runnable by git's own rule (is_executable in git's run-command.c).
On Linux and macOS, that means the owner execute bit is set. Windows has no execute bit, so the
file must start with #! there. Any other file fails the audit, and the failure names the path,
the file's size and its first line. A hand-written hook that runs the praetor commands also
fails: install the hook with a runner (lefthook install, praetorctl adopt or
pre-commit install).
When both configurations exist, the installed hook decides which one is checked. Lefthook's hook
and the fallback hook are checked against lefthook.yml, and .pre-commit-config.yaml is not
read. The framework's hook is checked against .pre-commit-config.yaml, even though
lefthook.yml exists. standards_audit and CI read no hook, so for them lefthook.yml alone
satisfies the gate.
A .pre-commit-config.yaml counts when its repo: local hooks run
praetorctl compile-context --verify and praetorctl audit at the pre-commit stage. Either
binary name counts, bare or by path, and args are appended to entry. If a hook sets no
stages, it takes default_stages, and if that is unset too, the hook runs at every stage.
commit is the old name for pre-commit. The file is read through config.ReadYAMLDocument,
which refuses a second document and duplicate keys. A configuration that passes:
repos:
- repo: local
hooks:
- id: praetor-compile-context
name: praetorctl compile-context --verify
entry: praetorctl compile-context --verify
language: system
pass_filenames: false
always_run: true
- id: praetor-audit
name: praetorctl audit --offline
entry: praetorctl audit --offline
language: system
pass_filenames: false
always_run: true
Tests: internal/adopt/hook_runner_audit_test.go covers each runner's hook (positive), the
placeholder and the other hooks the audit refuses (negative), and both configurations together,
core.hooksPath and the Windows rule (boundary). internal/adopt/hooks_path_audit_test.go
covers the core.hooksPath rule: /dev/null, a directory outside the repository, one inside
the working tree and one beside the managed directory fail, each holding lefthook's hook, and so
do global, environment, included and $XDG_CONFIG_HOME/git/config values; an unset value and the
managed directory pass, under a symlinked checkout path too, before that directory exists. It
also runs the printed fix for an included value and an XDG one, and checks where the
--reset-hooks-path hint appears. The TestAdopt_Hooks_Negative_*HooksPath* tests in internal/adopt/adopt_test.go check
that adoption refuses the same values with the same finding as the audit that follows it.
internal/adopt/precommit_config_audit_test.go
covers the configuration rules. It also checks the hooks that lefthook install and
pre-commit install write, wherever those tools are on PATH.
TestAudit_Boundary_HooksGate and TestAudit_Positive_PreCommitFrameworkRunner in
cmd/standardsctl/audit_cmd_test.go and TestServerAuditHookRunner_PreCommitFramework in
cmd/standards-mcp/audit_decline_test.go run the same rules through both audits.
Commit checks CI re-runs
The hooks prove nothing about a commit made without them: a one-off core.hooksPath override,
--no-verify, or a configuration the audit only reports afterwards. On every pull request, CI
therefore re-runs each commit check that needs nothing but the commit, over every commit of the
pull request (.github/workflows/ci.yml, the two steps after the documentation drift check):
| Hook check | Re-run in CI by | Test |
|---|---|---|
commit-msg: a conventional subject (build, chore, ci, docs, feat, fix, perf, refactor, revert, style or test, an optional scope and !), or a subject Git wrote for a merge or a revert |
praetorctl forge check-commits --base=origin/<base> --head=HEAD |
TestForgeCheckCommits_Negative_SubjectBreaksPolicy |
commit-msg: the HISS-14 Migration: footer on a breaking change |
the same command | TestForgeCheckCommits_EnforcesBreakingMigrationFooter |
commit-msg: closing keywords (close, fix, resolve and their forms, as GitHub lists them) are resolved in CI; issues pass, pull requests opened by the checked pull request author pass, and pull requests opened by anyone else fail unless a Closes-own: <reference> line marks the closure as intended. Offline, #N and owner/repo#N warn because only CI can resolve them; /pull/ and /pulls/ URLs fail immediately. |
the same command; praetorctl forge validate-pr applies the same function to the pull request body |
TestClosingKeywords_OnlineResolution |
commit-msg: the DCO sign-off |
scripts/dco_check.sh in .github/workflows/compliance.yml |
scripts/test_dco_check.py |
refactor: commits: REF-02 refuses changed assertion lines or removed test functions; REF-01 runs touched Go package tests at that commit |
pre-push runs praetorctl forge refactor-check --base=<base> --head=<head> without test execution; CI runs the same range with --run-tests |
TestForgeRefactorCheck_* |
pre-commit: whitespace errors and conflict markers (git diff --cached --check) |
python3 -B .config/lefthook/scripts/hooks.py commits origin/<base>, which runs git log --check over the range |
CommitRange in .config/lefthook/scripts/test_hooks.py |
pre-commit and pre-push: private .workingdir content |
the same stage, through check_private_history |
CommitRange |
The refactor: row checks the preparatory refactoring practice, from Martin Fowler's
Preparatory Refactoring
and Kent Beck's Tidy First?: the
reshape lands in its own behaviour-preserving commit before the change that needs it. The
preparatory-refactoring skill (.agents/skills/preparatory-refactoring/SKILL.md) tells agents
when to split one off; the assertion lock and the suite check are this repository's own.
The commit-msg hook and CI share one implementation of the message policy, forge.AnalyzeCommit
(internal/forge/commit_message.go). The hook runs praetorctl forge check-message <file> on the
message file Git hands it, which first drops what Git removes before it records the message: the
lines that start with #, and the scissors line with everything after it. A message file over
1 MiB, or of more than 100000 lines before the scissors line, is refused, not checked in part
(TestCleanCommitMessage_Boundary_LineBound).
standardsctl forge refactor-check uses the same Conventional Commits subject parser as
AnalyzeCommit to select refactor: commits. REF-02 checks changed *_test.go hunks for
t.Error, t.Errorf, t.Fatal, t.Fatalf, assert.*, require.*, and cmp.Diff lines, and
refuses removed Test* functions. A pure test-file rename passes. REF-01 runs go test for
packages touched by each refactor commit in a temporary worktree of that commit, bounded by a
20-minute test timeout; an unavailable or failing test command fails the gate. Pre-push omits
suite execution to keep the local range check fast. CI runs it with --run-tests on pull-request
ranges. REF-03 public API invariance remains covered by the engine API compatibility gate.
These checks are not re-run per commit:
block_evasion.py --environment(pre-commit,pre-rebase) inspects the environment of the process that commits, which is gone once the commit exists.- The live-state audit and
state sync --verify(pre-commit,commit-msg) read the workstation's private.workingdirledger, which no commit carries. - The file checks on the exported index (Python and JSON syntax, the YAML, shell, workflow and
Docker linters, gofmt,
go vet, the agent instructions and the hook self-tests) need each commit's tree exported and scanned, once per commit.make verify-allruns gofmt (fmt-check),go vet(lint) andcompile-context --verifyon the pull request's head tree instead, so a defect that a later commit of the same pull request removes is not reported.
Hook files adoption keeps
The audit compares none of the hook files below with a rendering, so praetorctl adopt --force
does not overwrite them. A file that still holds a text an earlier release wrote is refreshed on a plain run,
in its own line endings. An edited one is kept, --force included, with a warning reading
not audit-verified; kept. To regenerate it, delete it and re-run adopt.
| File | Unedited earlier text | Edited copy | Tests (internal/adopt) |
|---|---|---|---|
.config/agent/hooks/block_evasion.py |
refreshed to the current rendering (priorEvasionHookDigests, testdata/evasion) |
kept | TestAdopt_Positive_PriorEvasionHookRefreshedOnPlainRun, TestAdopt_Negative_EditedEvasionHookKeptUnderForce |
.config/lefthook/scripts/checkpoint.py and common.py, and .config/lefthook/python.sh |
refreshed to the --lock-source-root bundle (priorCheckpointDigests, testdata/checkpoint) |
kept; the checkpoint lifecycle is unavailable and no other bundle file is written | TestReconcileCheckpointBundle_Positive_PriorScriptRefreshedOnPlainRun, TestReconcileCheckpointBundle_Negative_NoHalfRefreshBesideAKeptFile, TestAdopt_Boundary_ForceKeepsDriftedCheckpointScriptLifecycleUnavailable |
.config/lefthook/engine.sh (the engine the hooks run) |
refreshed to the current text (priorEngineLauncherDigests) |
kept | TestAdopt_EngineLauncher_InstalledAndRefreshed, TestAdopt_EngineLauncher_EditedCopyKept, TestPriorEngineLauncherDigests_Boundary_CurrentTextRecorded, TestEngineMakefile_Positive_KeptCustomLefthookInstallsLauncherAndEngineMk, TestEngineMakefile_Boundary_KeptCustomLefthookKeepsEditedLauncherWithWarning |
the pre-commit hook in the directory git reports, written only when lefthook cannot install |
not applicable | a hook praetor did not write is kept; the audit accepts it only when lefthook or the pre-commit framework wrote it (the hook runner the audit accepts) | TestAdopt_Hooks_ForeignPreCommitKeptWithAndWithoutForce |
Beside a lefthook.yml that extends the canonical policy, the interceptor and the checkpoint
scripts belong to that vendored bundle and are never refreshed
(TestReconcileEvasionHook_Boundary_VendoredPriorKeptAndUnreadableUnverified,
TestReconcileCheckpointBundle_Boundary_VendoredPriorScriptKept,
TestAdopt_Boundary_CanonicalLefthookKeepsEarlierCheckpoint). Beside any other kept
lefthook.yml, the checkpoint row above applies, except that an edited copy leaves every bundle
file as it is without failing the lifecycle, since the kept file may run its own scripts. The
audit then names the edited file in its warning instead of a plain adopt run, which would not
refresh the bundle. Adoption reconciles
.config/lefthook/engine.sh and .config/praetor/engine.mk independently of whether lefthook.yml
is written (#1172): a repository whose own hook file is kept still receives the launcher and Makefile include
to resolve its pinned engine, without overwriting or activating its hook file. Earlier releases renamed a
foreign pre-commit hook to pre-commit.bak under --force. Adoption now reports such a
file and leaves it in place (TestAdopt_Hooks_LegacyPreCommitBackupReportedNotRemoved).
Each set holds the current text too. After changing the interceptor template, an engine rule
it renders, or either checkpoint script, copy the new text under internal/adopt/testdata/evasion
or internal/adopt/testdata/checkpoint and add its digest to the set: the interceptor's is the
file TestEmittedHookFixturesMatchTheRendering regenerates.
TestPriorEvasionHookDigests_Boundary_CurrentRenderingRecorded and
TestPriorCheckpointDigests_Boundary_CurrentSourcesRecorded fail until the text is recorded,
so the next release still refreshes the copy adopters hold.
What a hook run prints
A hook run prints the output of its jobs and, when a job fails, Lefthook's exit
status plus one ✗ <job> line naming it. There is no version banner, summary block,
per-job success line or color. .config/lefthook/praetor.yml sets this with
output: [execution_out, failure] and colors: false.
Every run is read by an agent: the hooks fire on each commit, checkout and push and on agent lifecycle events, for the main session and for every subagent. Measured with Lefthook 2.1.12 on the same inputs, before and after the policy:
| Run | Characters before | Characters after | Escape sequences before |
|---|---|---|---|
agent-pre-tool, allowed command |
1,868 | 27 | 106 |
agent-pre-tool, blocked command |
2,074 | 266 | 106 |
agent-checkpoint-tool |
2,379 | 319 | 120 |
agent-state-stop |
2,092 | 185 | 110 |
pre-commit, one staged file |
1,857 | 158 | 98 |
None of these runs prints an escape sequence after the change. The markers the native adapters
read (PRAETOR_COMMAND_POLICY_OK, PRAETOR_CHECKPOINT_SCOPE_OK,
PRAETOR_CHECKPOINT_RESULT=, PRAETOR_STATE_RESULT=) are job output, so they are
printed exactly as before. test_hook_output_is_job_output_and_failures_only in
.config/lefthook/scripts/test_hooks.py pins both halves: a pass prints only its
marker, and a blocked command still prints its reason and the failed job's name.
The diagnostics these scripts print are agent text, so .config/lefthook/scripts
and .config/agent/hooks are register.sources inputs in .standards.yaml. A
static message or f-string template is linted in the internal register. A value
that is entirely computed, such as a JSON result, passthrough tool output or one of
the markers above, carries one exact classification comment on its line:
# caveman:not-applicable structured-protocol, untrusted-passthrough or
protocol-marker. Changing a message changes the contract digest; refresh
expected, not_applicable and sha256 from
praetorctl caveman check --configured-sources --root=.. The extraction rules are in
the text-register guide.
For Lefthook's full reporting on one run, set LEFTHOOK_OUTPUT, for example
LEFTHOOK_OUTPUT=meta,summary,execution git commit -s. The
pinned Lefthook output reference
lists the values. A personal lefthook-local.yml can set output or colors; it is
loaded after extends and wins.
Remote checkpoints
The pre-push hook reserves a separate checkpoint/* namespace for unfinished audit work
(push_check_mode in .config/lefthook/scripts/hooks.py).
Pushes to it require the same snapshot file checks plus affected Go builds and
race tests. Full CI runs on every checkpoint push; a checkpoint is a WIP backup,
not a passing verification receipt or permission to merge. Fixes can therefore be
shared while the remaining repository audit findings stay visible in CI.
Only the actual destination under refs/heads/checkpoint/ selects this policy.
Other branches, tags, and PR/merge gates retain their strict checks. A push that
contains both checkpoint and strict refs must pass both policies, even when they
point at the same commit. No environment flag disables a gate.
The separately configured review policy supports explicit single-maintainer operation while another reviewer is unavailable. This changes the hosted approval requirement; the Git hook and verification gates still run.
Pre-push consumes Git's ref protocol once and checks disposable clones of those commit IDs. It handles multiple refs, new branches, tag targets, deletions and pushes that do not use the current checkout. An existing branch uses the remote OID supplied by Git; a new branch first uses the known remote default branch's ancestor, then other eligible remote ancestors. Strict pushes exclude checkpoint baselines. Missing ancestry or an unavailable remote object selects a conservative full-tree check. Empty input and ref deletions have no new content to validate.
The stdin-consuming pre-push entry is a Lefthook script job. It runs even when Lefthook estimates an empty final file diff, so intermediate commits that add and then remove private content still reach the history check.
Go scope includes changed packages, test-only reverse dependencies, testdata and
embedded inputs of any extension. Module/build/security configuration selects all
packages. Deleted files beneath a package with embed patterns conservatively
select that package and its dependents. Go's package metadata is inspected before
skipping documentation, so embedded Markdown is still tested. Ordinary docs and
state edits skip Go race/security gates. A root README.md edit still runs the
governance and flavor audits, so a stale or malformed managed governance block
cannot pass through the lightweight documentation path. Governance and flavor
audits also run for source and policy/configuration edits. When the touched-file CSV
exceeds the single-argument limit, the snapshot governance audit passes --base alone
so praetorctl audit resolves changed files from Git directly, avoiding argument-length
limits (engine-hooks-audit-touched-e2big-fix-20261026). The live private ledger is checked outside
the exported snapshot; it never becomes published snapshot content. Source and
governance pushes also retain the full gate run pipeline and pinned-key
gate verify. The hook passes its existing Go selection explicitly as
--race-packages=<comma-separated import paths> to gate run, or
--race-packages=none when empty. The race stage and signed race_packages header
record that selection; the empty Go stage is not_applicable with the reason
no affected Go package (--race-packages=none). Security stages retain their
module scope, and golangci-lint uses affected packages. Verified receipts are retained under
Git metadata at praetor-receipts/<commit>.json, without staging an artifact. Existing
P0 blockers, audit failures, zero-warning lint failures and all gosec rules remain
enforced. gate run without --race-packages and CI retain full-module race tests.
The CI filter recognises frontend sources, styles, HTML and app configuration,
including the console workflow and documentation. Before choosing a light run, it
checks tracked Go package directories; a file in or below one conservatively
selects Go tests, covering testdata and embedded inputs of any extension. Unknown
file kinds still fail closed.
The hook bounds the gate run and gate verify subprocesses by the gate's own run deadline,
not by a figure of its own. run_full_gate in
checks.py first runs gate deadline --json, which
resolves PRAETOR_TEST_STAGE_TIMEOUT through the pipeline's parser: the race-stage bound, clamped
to its 30-minute ceiling, once per test suite the repository holds, plus the allowance for the
other stages. It runs from the repository root, so a root holding both a go.mod and a
Cargo.lock gets the two-suite deadline gate run grants itself. It then adds a two-minute
launch margin (GATE_LAUNCH_MARGIN) for go run to rebuild the CLI. With the variable unset that
is 8 + 2 = 10 minutes; at the ceiling it is 37 minutes, so a raised bound takes effect on a push
(#314). A deadline report the hook cannot use fails the
push rather than running the gate under a guessed bound. The deadline itself is described in
adoption verification.
When that bound expires, or Ctrl-C interrupts the push, the hook stops the gate before it kills
it. stop_process_group in common.py sends the
child's process group SIGTERM (SIGINT for Ctrl-C), waits up to STOP_GRACE (10 seconds) for every
member to exit, and only then sends SIGKILL; a second Ctrl-C during the wait kills at once. A
group id of 1 or less, a non-integer (a test double's pid reads as 1) or the hook's own group is
never signalled: os.killpg(1, sig) is kill(-1, sig) and reaches every process the user owns
(cordanaLLM/praetor#1437); _signal_group refuses it, and HISS-08 reports any such call left
unbounded in an adopter's code. The
CLI runs each git and go command in a process group of its own and forwards a catchable signal to
those groups, waiting up to 5 seconds for them (internal/util/command_interrupt_unix.go), so git
removes its index lock and nothing is left running. gate run forwards SIGINT, SIGTERM and SIGHUP
the same way but does not end on them: it cancels the run, removes the race stage's isolated
worktree and its wt/* branch, and exits with the shell's 128+signal status (130, 143 or 129), not
the 1 of a rejection (CancelCommandsOnSignal in internal/util/command_signal_context.go). A
signal the CLI started with ignored, as under nohup, stays ignored. A Ctrl-C after the first
signal ends it at once; a repeated SIGHUP or SIGTERM does not, because closing a terminal sends
SIGHUP twice (the shell forwards it to its jobs, and the kernel sends it again when the shell
exits), and the cleanup the first one started is bounded.
TestGateRun_Positive_TerminatingSignalCleansUpWorktreeAndBranch,
TestGateRun_Positive_RepeatedHangupStillCleansUp and
TestGateRun_Negative_IgnoredHangupKeepsTheRunGoing in cmd/standardsctl/signal_unix_test.go
replay these cases. SIGKILL on the CLI's group cannot be forwarded
and misses those groups. On Linux the kernel still kills each command when the CLI dies
(internal/util/command_parent_death_linux.go); the processes a command started, and every
command on macOS, would run on. The test_stop_* cases in
test_hooks.py replay the forwarded stop, the kill
after the grace, and the second Ctrl-C.
Before snapshot governance checks, the disposable clone initializes its own missing private ledger and audits it. Incomplete or invalid existing state still fails. The live ledger is never copied into the clone.
Snapshot Git reads ignore configuration injected through the environment:
GIT_CONFIG_COUNT, GIT_CONFIG_KEY_<n>, GIT_CONFIG_VALUE_<n> and
GIT_CONFIG_PARAMETERS (what git -c key=value push sets) are removed, so a
one-off setting cannot change the bytes a gate checks. Commit-time exports keep
GIT_INDEX_FILE, GIT_DIR and GIT_WORK_TREE as Git set them, so
git commit -a and git commit -- <path> are checked against the index they
record, not the stale .git/index. Ref-backed snapshots use a fully clean
environment. .config/lefthook/scripts/common.py (index_env, clean_env)
implements this, and .config/lefthook/scripts/test_hooks.py covers both paths.
After reviewing and staging your work, run praetorctl state sync ., then
git commit -s -m 'fix(scope): describe the change'. Task, bug, question, staged,
unstaged or untracked input changes require another sync. The hook does not create
a DCO attestation or task narrative on your behalf. For a merge that changes the
index, use git merge --no-commit, inspect the result, sync, then commit. Run the
required praetorctl state sync . at turn end; keep the ledger local and untracked.
make state-audit initializes a missing ledger in a fresh checkout, then audits it;
existing incomplete, malformed, or P0-blocked state still fails. Post hooks cannot
undo an operation that already succeeded;
resolve any reported refresh failure before continuing.
praetorctl state sync --verify . is read-only. It requires a versioned SHA-256
binding in the final STATE entry covering its complete preceding history, the
absolute worktree root, branch and full HEAD (or explicitly verified unborn
branch), index entries, staged and unstaged binary Git diffs, visible Git status,
untracked files, and OPEN, BACKLOG, BUGS and QUESTIONS bytes. STATE records
the observed Git state, clean flag and dirty count. state status reports an
inspection time, never a fabricated last-sync timestamp.
Only these canonical ledgers are read inside .workingdir; evidence, memories
and caches are excluded. Git-ignored public paths are also outside the binding.
The sync is bounded by bytes and time, not by repository size: 1 MiB per canonical
ledger, 16 MiB for the index listing, 8 MiB for every other Git output stream,
five-second Git probe deadlines, and at most 1,048,576 records per Git listing.
An untracked regular file of at most 1 MiB is bound by its bytes while 8 MiB of
untracked content remains; any other untracked path (a symlink, named pipe,
nested repository, larger file, or one past that budget) is bound by its type,
size and modification time instead of failing the sync. Missing or oversized
ledgers, irregular tracked files, unsupported submodules,
assume-unchanged/skip-worktree entries, and configured Git clean or process
filters fail explicitly and name the path. The
state ledger integrity guide
lists every binding input and bound. These checks establish synchronized bytes;
they cannot establish that an agent recorded every relevant task or explanation.
The gate's untracked Exit-0 receipt .standards-receipt.json at the root is outside
the binding too, so a praetorctl gate run after the sync leaves the ledger current
(#136); the state ledger integrity guide states why.
This repository's native Stop/AfterAgent registration reaches the engine, which runs
state sync --verify and state audit itself; with no engine the launcher falls back
to the bridge that calls the shared agent-state-stop job and requires its unique
success marker. Missing, stale or invalid state blocks
completion. The Git hooks rebuild the checkout CLI before ledger checks to avoid
accepting an older installed binary. Generated adoption bundles require their own
compatible CLI and hook integration; this repository's activation is not evidence
that every adopted client enforces state freshness.
The native pre-edit bridge (Claude Edit/Write, Gemini replace/write_file) calls the
shared agent-checkpoint-pre-edit job, .config/lefthook/scripts/checkpoint_scope.py, and
requires PRAETOR_CHECKPOINT_SCOPE_OK. Until a checkpoint is due it checks only the payload's
shape, so a path or session cwd outside the repository passes. Once one is due, both must sit
inside this repository (see checkpoint cadence), which the job decides
by filesystem identity rather than by how the client spelled the path. How each client
locates the bridges is described in
agent hooks.
Useful local commands:
make check-staged
make changed-packages BASE=origin/main
make test-changed BASE=origin/main
make lint-changed BASE=origin/main
make sec-changed BASE=origin/main
make check-changed BASE=origin/main
make hooks-test
make verify-all
make sandbox-verify REF=HEAD
The *-changed targets compare committed HEAD against BASE and use exactly
the same scope and process runner as pre-push. make verify-all retains the full
repository checks. Independent checks run with at most three workers; command
failures are collected and propagated.
A scoped run that takes the vulnerability check (make check-changed when no governance audit
runs, or python3 .config/lefthook/scripts/hooks.py changed vuln <base>) starts the
Go vulnerability gate
with go run ./cmd/standardsctl security govuln -- govulncheck, over the whole module: a
module-level finding has no package to scope by. When the full gate runs, gate run makes the
same check.
CI sets TEST_COVERPROFILE to an explicit temporary file and obtains coverage
from the race run inside make verify-all. The same run must meet the 65%
statement-coverage floor; CI does not execute a second full race suite.
The sandbox runner requires Docker and the explicitly selected local
praetor-dev:audit image. Override its name with PRAETOR_SANDBOX_IMAGE. It clones
an exact commit, creates a private home/cache, runs as the invoking UID, passes
CI=true, uses readonly module mode and removes the clone afterward. It never
copies uncommitted changes or silently ignores snapshot failures. Enable the
additional full sandbox gate on each pushed ref with PRAETOR_HOOK_SANDBOX=1.
It is opt-in because the existing development image may lack required tools;
make sandbox-verify fails clearly when the image or a gate is unavailable. A
successful documentation-only push does not generate a receipt. A source or
governance push requires the verified receipt, but does not imply the optional
Docker sandbox ran. Existing audit failures, missing scanners or missing signing
configuration remain blocking failures; the sandbox gate does not bypass them.
The command guard accepts actual PreToolUse JSON or command arguments; Git calls
its explicit environment mode. It rejects verification-evasion commands and
hook exclusions, and an adopt, conform, bootstrap or needs command aimed
at the workstation dev root itself. It names no organisation folder: a command
aimed at ~/dev/<folder> passes, and an operator who wants a folder refused adds a
pattern to hooks.command_policy.deny, which the Go hook entrypoint applies (see
Agent hooks;
test_guard_blocks_the_dev_root_and_names_no_organisation_folder in
.config/lefthook/scripts/test_hooks.py). It also refuses, without scanning, a command over 65,536
characters or with a line over 2,048 characters: Python's re backtracks, and a
longer line could hold the guard past the client's hook timeout (see
Agent hooks;
test_guard_refuses_commands_over_the_scan_bound_without_scanning in
.config/lefthook/scripts/test_hooks.py). Split such a command, or write the long
content to a file first. A hook cannot intercept a Git invocation that disables all
hooks: repository protections and CI remain authoritative. Local configuration
must not disable required gates.
Hook-policy changes run hooks-test as part of their staged/push file checks.
The behavioral suite uses disposable repositories with installed Lefthook and
real commits/pushes. It includes staged/unstaged isolation, arbitrary filenames,
empty and deletion-only changes, malformed syntax/messages/protocols, negative
vet/race controls, embedded-input and reverse-dependency scope, governance gate
selection and every configured stage. It never disables the user's hooks.
Native Codex command guard
The Git hook policy above is shared by humans and coding agents. This section describes the separate native Codex adapter; it is not coverage for every tool host or client. Other agents can invoke the shared Lefthook policy explicitly and need a client-specific native adapter before claiming lifecycle coverage.
Git hooks run when Codex invokes Git, just as they do for a human. They do not
inspect every tool call or run verification at the end of a conversation turn.
The repository's .codex/hooks.json adds a separate PreToolUse handler for
Codex's native Bash tool. Specialized tool hosts and existing sessions require
separate runtime verification; this file does not establish their coverage.
The handler invokes codex_pre_tool.py, which executes
lefthook run agent-pre-tool --no-tty --no-auto-install. That required job runs
block_evasion.py. The adapter requires its success marker as well as exit zero;
a missing binary, missing job or skipped job blocks execution. Automatic Git
hook installation is disabled for this tool event only; install Git hooks with
make hooks. Git's hook driver calls the policy's environment mode;
Codex supplies the proposed shell command as JSON before execution.
The adapter translates rejected commands, invalid input, missing guard execution and subprocess failures into Codex's blocking exit code 2. Input is capped at 1 MiB, guard execution at ten seconds, and diagnostics at 4 KiB. This is a screen for known verification-evasion and topology patterns, not a complete shell parser or an immutable security boundary. It does not inspect file edits, arbitrary MCP calls, or later input sent to an already-running shell.
This integration is checked against Codex CLI 0.145.0 and Lefthook 2.1.14. Version 2 supports custom agent lifecycle jobs; the earlier 1.13.6 validator rejected them. CI installs the same pinned v2 release. The adapter translates Lefthook's failure into Codex's blocking exit code rather than assuming their exit semantics match. See the upstream agent integration.
After opening this trusted repository in Codex, use /hooks to review and trust
the repository's PreToolUse definition. If it is not listed in an existing
session, start a new session from the repository. New or changed definitions are
skipped until trusted. User-level Hindsight hooks load alongside this handler.
The adapter's subprocess tests establish its behavior; activation additionally
requires a native Codex hook event after trust. A checked-in hook file alone is
not evidence that the current session is enforcing it.
The repository configures PostToolUse and Stop checkpoint hooks. They share
Lefthook's checkpoint evaluator and require reviewed public changes to reach a
commit, gated push and draft PR according to the configured policy. They do not
replace make verify-all or certify the code themselves. See the
checkpoint guide for due results, local-only settings,
bounded continuation and activation evidence. Current-session interception still
requires a real native hook event; configuration alone does not establish it.
Native bootstrap can also be inspected without a model request:
python3 -B scripts/dev_codex_hooks.py status
python3 -B scripts/dev_codex_hooks.py trust --key '<reviewed project hook key>' --expected-hash 'sha256:<reviewed definition hash>'
Review the listed command and its referenced source before supplying its hash.
The bootstrap uses Codex 0.145.0's native hooks/list and config/batchWrite
operations, matching the native trust UI. It only updates the selected enabled
project command hook, rejects stale hashes and discovery warnings, and verifies
trusted status afterward. It preserves other hook and client settings. It does
not start an agent or reload an already-running IDE session. A definition hash
binds the command configuration, not the contents of a referenced script; source
review and the repository gates are still required after implementation changes.
The client shares MCP's bounded stdio transport, including deadlines, byte and request limits, and child-process cleanup. Tests cover stale/foreign/duplicate selection, failed discovery and readback, and real native-protocol pipe exchange.
What a push scans, and against what
A push of an existing branch is scanned against the ref it is replacing. A push of a new
branch has no such ref, so the base is the merge-base with the remote's default branch, obtained
from git ls-remote --symref <remote> HEAD.
The remote is asked rather than refs/remotes/<remote>/HEAD being trusted. Git writes that
symref once at clone time and never updates it: it is inherited when a clone is cloned,
git remote set-head can point it anywhere, and nothing resets it when the remote's default
changes. Trusting it made the scan scope depend on a local ref nobody set deliberately — in one
checkout it named a feature branch, the base resolved six merges stale, and the staged scan grew
from 3 files to 36. That was enough for semgrep-core to exceed the host's memlock limit and
refuse the push with Cannot allocate memory io_uring_queue_init, an error naming memory, the
kernel and semgrep, and never the scope.
A push is already a network operation, so asking costs nothing new. An unreachable remote falls back to the local symref rather than failing the push, and says so:
Every push then prints what it is about to scan and the base that produced it:
That line exists so a wrong scope reads as a wrong number rather than as whatever the scanner does when handed too much work.
Fixture inputs are excluded from source scans
is_fixture() in .config/lefthook/scripts/checks.py treats any path under a testdata/
directory as input to the rules rather than source governed by them, and the gofmt and semgrep
selections skip it.
gofmt_check() is the one gofmt check. Pre-commit runs it on the staged set, and make
fmt-check (hooks.py fmt-check), which make lint depends on and the CI gofmt step calls, runs
it on every tracked Go file. It hands gofmt at most GOFMT_BATCH files per run, so a whole-tree
check stays under the Windows command-line limit
(test_fmt_check_skips_testdata_and_names_unformatted_source and
test_gofmt_check_batches_cover_every_file in .config/lefthook/scripts/test_hooks.py).
This is not a convenience. The HISS-20 corpus under .config/hiss/testdata/ exists because its
positive fixtures violate an invariant — a file that fails to be a bounded loop is how the rule is
proven to fire. Scanning them reports the engine's own test data as the repository's debt and blocks
every push that touches the corpus. The HISS scanner, the dedupe scan, gitleaks and make
fmt-check all skip the same directory name, which is Go's own convention for the same reason.
The semgrep stage has two forms and both apply the exclusion. A per-file scan drops fixture paths
from its file list. When a pushed range touches .config/semgrep/, the scan widens to the whole
tree and passes --exclude testdata to semgrep instead, because a bare . would walk into the
corpus. Both forms read the directory name from FIXTURE_DIRECTORY, so they cannot disagree.
Which changed files select which checks
.config/lefthook/scripts/checks.py classifies each changed path once, and each set is held to its
real source by a test in .config/lefthook/scripts/test_hooks.py:
| Set | Selects | Matches | Held in step by |
|---|---|---|---|
CONTEXT, CONTEXT_PREFIXES |
compile-context --verify |
AGENTS.md, AGENTS.readonly.md, .agents/**, the six vendor files, the .claude/, .codex/, .gemini/, .github/ agents/ persona directories and the .claude/skills/ copies of the register skills |
test_context_changed_covers_every_compile_context_path runs the real compile-context and requires every file it reads or writes to match |
GO_EXTENSIONS |
the Go packages to build, test, lint and scan | every suffix go/build compiles into a package: .go, cgo C/C++/Objective-C sources and headers, assembler (.s, .S, .sx), Fortran, SWIG and .syso |
test_go_packages_selects_cgo_and_assembler_inputs |
SEMGREP_SUFFIXES |
the per-file semgrep scan | the extensions semgrep assigns each language in .config/semgrep/hiss-invariants.yml, including .h, .hpp, .cc, .jsx and .tsx |
test_semgrep_suffixes_cover_every_rule_language fails when a rule names a language the table lacks |
Before these sets were widened, a commit touching only a persona or skill skipped
compile-context --verify, and headers, C++ and JSX/TSX sources never reached the semgrep rules
written for them. A Go package directory go list reports outside the snapshot is a hook error
naming it, not a Python traceback.
Running the gate on Windows
A Windows checkout depends on four platform behaviours. Each one blocks git commit if it is
missing, so all four must hold. Two programs must also prove themselves there: the hooks'
interpreter, which Windows does not reliably provide under the name python3
(the interpreter the hooks run), and GNU Make
(the make the hooks run).
- The binary is built as
praetorctl.exeon Windows.Makefilederives the suffix from$(OS), so every target names the binary for the host rather than for the developer's platform. - The hook launches it by absolute path.
CreateProcessWcannot resolve a bare relative path that uses forward slashes and lacks a./prefix, sobin/praetorctlfailed before any check ran. The path resolves from the working directory, because the hook runs inside the repository being committed — during the harness self-tests that is a temporary fixture with its ownbin/. - Directory
fsyncis skipped on Windows. It is POSIX-only and NTFS refuses it on a directory handle, which failed thecommit-msglive-state gate on every commit. - The harness self-tests run everywhere, and skip only what they cannot build.
A fresh worktree needs its ledger
praetorctl state init --if-absent . is required in every new worktree, on any platform, or the
gate fails with working directory .workingdir does not exist. .workingdir/ is gitignored and
per-worktree, so it does not arrive with a checkout.
Race-detector legs need cgo
go test -race requires cgo and a host C toolchain. Where CGO_ENABLED is 0 — a Windows box
without gcc, for instance — five self-test legs skip and say why:
A skipped leg that read as a pass would certify what it never ran. CI runs on Linux with cgo, so the coverage is deferred rather than lost.
This mattered beyond convenience. The self-tests trigger on any change under .config/lefthook/,
which is exactly where a contributor fixing Windows support has to work — so the gate could not be
repaired from the platform it was broken on.
The hook scripts themselves run on Windows
Five POSIX-only calls in the scripts Lefthook invokes failed on Windows, each of them after the CLI had already started:
- The checkpoint policy read.
checkpoint.pyopened the policy withO_DIRECTORY,O_NOFOLLOWanddir_fd, none of which exist on Windows, so the evaluator every adopted repository runs raisedAttributeErrorbefore it read anything. The descriptor path is kept wherever the platform supports it. Elsewhere a checked reader inspects each component without following it, refuses links — junctions and other reparse points included — and non-directories with the same errors, and requires the file it opened to be the file it inspected. It detects a component replaced between the two, rather than preventing it, and says so. Both readers hold one directory descriptor at a time and close every descriptor they open, also when the policy path turns out to be a directory (.config/lefthook/scripts/test_checkpoint.py). - Bounded subprocess output.
common.pywaited on pipes withselectors;select()on Windows accepts only sockets, so every boundedgitcall raisedOSError. Windows drains each pipe on its own thread under the same shared byte limit and deadline. - Stopping a bounded command that forked.
common.pystops the child's whole process group when a bound is exceeded, and Windows has no process group to signal. Killing the direct child alone left its children running past the bound, which is whattest_hooks.pyobserved when a grandchild wrote its marker after the parent had been terminated. Windows askstaskkill /F /Tto walk the descendants instead, and the direct kill remains the floor where that cannot run. Both bounded runners share the one function, so the two do not diverge. - The sandbox's user mapping.
sandbox.pypassed--user $(id -u):$(id -g)so a bind mount keeps host ownership.os.getuiddoes not exist on Windows, where Docker Desktop maps that ownership itself, so the flag is omitted there. - The command-policy marker.
block_evasion.pyprintedPRAETOR_COMMAND_POLICY_OKthrough text mode, which Windows writes with CRLF. It is written as exact bytes.
One prepare-commit-msg defect surfaced with them and was never Windows-specific: the
instructional comment was added only when the message source was empty. Git omits that argument
entirely for a plain editor commit, and Lefthook then renders {2} as the literal string 2, so
the commit that most needs the comment never received it on any platform. Only the sources git
documents — message, template, merge, squash, commit — count as a source.