Hooks, test gate, CI
The gates are generated files, tracked in the repository and rendered from .persist/config.json. Cheap checks on commit, expensive ones on push, doctor again in CI.
The generated hooks
persist init writes two POSIX sh hooks under
.persist/hooks/. They are ordinary tracked files, so they travel with the
clone and show up in code review like anything else. The pre-commit hook runs the
doctor and then each preCommitGates entry in order:
#!/bin/sh
# Persist OS pre-commit hook.
# Generated by `persist init`. Edit gates in .persist/config.json (preCommitGates),
# then run `persist hooks sync` to regenerate this hook.
# Enable once per clone with:
# git config core.hooksPath .persist/hooks
set -e
# Use the installed persist, or npx when it is not installed globally.
run_persist() {
if command -v persist >/dev/null 2>&1; then
persist "$@"
else
npx --yes persist-os "$@"
fi
}
# Doctor warnings are advisory: they print but never block the commit.
# Only errors fail the hook. The set +e pair is deliberate — under set -e
# the shell would abort before $? can be read.
set +e
run_persist doctor
status=$?
set -e
[ "$status" -le 1 ] || exit "$status"
The pre-push hook is the expensive gate. It runs persist test-gate, then
each prePushGates entry. The doctor is not repeated here; it already ran on
commit. This is what init generated in a repository whose package.json
has test:run, typecheck, and lint scripts and a
pnpm-lock.yaml:
#!/bin/sh
# Persist OS pre-push hook.
# Generated by `persist init`. The expensive gate before code leaves your machine: it runs
# `persist test-gate` (your configured testCommand, skipped loudly when unset) and your
# push-only gates against everything being pushed (catching commits made with --no-verify
# or before the pre-commit hook was active).
# Edit the test command (testCommand) and gates (prePushGates) in .persist/config.json,
# then run `persist hooks sync` to regenerate this hook.
# Enable once per clone with:
# git config core.hooksPath .persist/hooks
set -e
# Use the installed persist, or npx when it is not installed globally.
run_persist() {
if command -v persist >/dev/null 2>&1; then
persist "$@"
else
npx --yes persist-os "$@"
fi
}
run_persist test-gate
# testCommand: pnpm run test:run
pnpm run typecheck
pnpm run lint
Pre-push catches what pre-commit could not: commits made with --no-verify,
commits from a machine where the hook was never enabled, or commits made before it was.
Enabling them
Git never switches on hooks that arrive with a clone, so each clone opts in once.
persist init asks and does it for you: the default is yes,
--yes takes it, and --no-enable-hooks skips it. It never
replaces a core.hooksPath that another hooks tool, such as Husky, already
owns. On any other clone, run the one line yourself; persist doctor
reminds you until you do, except in CI, where hooks never apply:
$ git config core.hooksPath .persist/hooks
The hooks call your installed persist, or npx persist-os
when it isn't installed globally, so a project that only ever used
npx persist-os init still commits cleanly.
What they look like when they fire
Recorded in a fresh repository right after persist init. The first commit
passes; the doctor's report is the hook's output, followed by git's own summary:
$ git commit -m "Add repository memory"
Doctor Report
INFO
- Persist OS config validates. (.persist/config.json)
- 0 feature folders detected.
- 0 module folders detected.
- 0 ADRs detected.
NOT EVALUATED
- standards: no feature folders or ADRs exist, so there are no completion claims or decisions to check
- content: no feature folders, module folders, or ADRs exist, so there is no memory content to check
- governing-adrs: no accepted ADR lists the paths it governs (an Applies To section), so no change can be matched to a decision
- context-cards: no context cards exist, so there is nothing to check for dead paths or staleness
Result: PASSED
[main (root-commit) 8c93abe] Add repository memory
33 files changed, 1040 insertions(+)
…
A commit that deletes a required document is refused with an error:
$ git commit -m "Remove the product file"
Doctor Report
ERROR
- Required file is missing. (docs/00-product/PRODUCT.md)
INFO
- Persist OS config validates. (.persist/config.json)
- 0 feature folders detected.
- 0 module folders detected.
- 0 ADRs detected.
…
Result: FAILED
[exit 1]
Warnings print but never block the commit. The hook reads the doctor's exit code and
only fails on errors, so unfilled template sections are visible without holding the
commit hostage. A gate that cried wolf over templates would teach everyone to reach
for --no-verify — which would also silence the errors that matter. Fill
the sections the report names, then commit again:
$ git commit -m "Plan the checkout feature"
Doctor Report
WARNING
- Security model authentication and authorization section is still an unfilled template. (docs/20-security/SECURITY_MODEL.md)
- Product purpose is still an unfilled template. (docs/00-product/PRODUCT.md)
- Product users section is still an unfilled template. (docs/00-product/PRODUCT.md)
- Conventions canonical-primitives section is still an unfilled template. (docs/60-engineering/CONVENTIONS.md)
…
Result: WARNINGS
[main 305fdb2] Plan the checkout feature
2 files changed, 50 insertions(+)
[exit 0]
$ git log --oneline
305fdb2 Plan the checkout feature
8c93abe Add repository memory
Pre-push runs the test gate. With a failing suite the push is refused, and git says so after the gate's output:
$ git push -u origin main
Persist OS test gate failed: npm run test:run exited with code 1.
> orders-api@1.4.0 test:run
> node --test
✖ totals a cart (0.861292ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
…
✖ failing tests:
test at tests/cart.test.js:5:1
✖ totals a cart (0.861292ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
24 !== 29
…
error: failed to push some refs to '../origin.git'
[exit 1]
With the suite fixed, the same push goes through:
$ git push -u origin main
Persist OS test gate passed: npm run test:run
> orders-api@1.4.0 test:run
> node --test
✔ totals a cart (0.614041ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
…
To ../origin.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
[exit 0]
How the gates are detected
Init reads the repository's manifests and the lockfile and records what it finds in
.persist/config.json. Detection only proposes one-shot commands as
editable config values: bare vitest or anything with
--watch is never chosen, because a hook that hangs is worse than no
hook. With nothing usable, testCommand is null and init
says so. Detection only reads files; it never runs the tools it names.
| Stack signal | Test command | Push gates |
|---|---|---|
composer.json with a scripts.test entry |
composer test |
none |
composer.json requiring laravel/framework, and an artisan file |
php artisan test |
vendor/bin/pint --test when laravel/pint is required; vendor/bin/phpstan analyse when phpstan.neon exists |
composer.json requiring pestphp/pest or phpunit/phpunit |
vendor/bin/pest or vendor/bin/phpunit |
same PHP gates as above |
pytest in pyproject.toml, requirements*.txt, setup.cfg, or a [tool.pytest…] section |
pytest (uv run with uv.lock, poetry run with poetry.lock) |
ruff check . and mypy . when configured |
Django (manage.py) without pytest |
python manage.py test |
same Python gates |
go.mod |
go test ./... |
go vet ./... |
Cargo.toml |
cargo test |
cargo clippy only when Clippy is configured |
Gemfile listing rspec, else rails |
bundle exec rspec or bin/rails test |
bundle exec rubocop when configured |
package.json: a test:run script, else a one-shot test script (pnpm-lock.yaml, yarn.lock, or npm by default) |
<pm> run test:run or <pm> run test |
<pm> run typecheck and <pm> run lint, in that order, when those scripts exist |
Makefile with a test: target, and nothing above matched |
make test |
none |
When more than one stack matches, the first row in the table wins — except that a
package.json with a usable test script only outranks the
Makefile fallback, so a Laravel app with a Vite
package.json still runs php artisan test. Init prints
what it chose and what else it detected, so the choice is visible and easy to
change. preCommitGates stays empty by default: add whatever you want
to run on every commit after the doctor.
The config init wrote for the example repository above:
"preCommitGates": [],
"prePushGates": [
"pnpm run typecheck",
"pnpm run lint"
],
"testCommand": "pnpm run test:run"
Gate entries are single lines without control characters, up to 200 characters, and there can be at most 50 of each. A JavaScript-only repository resolves exactly as it always has.
Editing and regenerating
The config is the source of truth and the hooks are rendered from it. After changing
the config, regenerate the hooks with persist hooks sync
(--dry-run first to preview). It rewrites only the generated hook scripts
and never touches docs or config. .claude/settings.json is created when
missing and otherwise left alone, because it often holds your own settings.
init --force --reinit is not a way to do this: it rewrites every generated
file, filled-in docs included, and resets the config to defaults. If you hand-edit a
hook instead, that is allowed, and the doctor's hook-drift check keeps
reporting the difference as a warning so it stays visible.
The per-prompt context hook
The SessionStart hook loads the memory map once per session; the prompt hook looks
up context cards for every submitted prompt. Claude Code and Codex both hand a
prompt hook its input as JSON on stdin with a prompt field, and both
read injected context back from additionalContext — so one lookup
command, persist context --hook, serves both tools. It prints at most
about 1,500 bytes of pointers, prints nothing below the match threshold, and never
fails the prompt.
#!/bin/sh
# Persist OS Claude Code UserPromptSubmit hook.
# Generated by `persist init`. Looks up context cards for the submitted prompt and injects
# pointers (never whole files) into the prompt. Read-only: the prompt text stays in this
# process and is never written to disk or logged.
# Wired in .claude/settings.json; that file decides the timeout.
input=$(cat)
if command -v persist >/dev/null 2>&1; then
printf '%s' "$input" | persist context --hook claude
elif [ -x node_modules/.bin/persist ]; then
printf '%s' "$input" | node_modules/.bin/persist context --hook claude
fi
exit 0
The script always exits 0, runs only when persist is on
PATH or in the project's node_modules/.bin, and never
calls npx — far too slow on every prompt. The short timeout lives in
the wiring: 10 seconds in .claude/settings.json and in
.codex/hooks.json, which init creates when missing and otherwise leaves
alone, because both often hold your own entries. Set
contextHook to false in
.persist/config.json to stop generating and expecting these files.
Cursor's hook API can validate or block a prompt but cannot return context, so
Cursor is deliberately not wired: the context skill and the Cursor
rule line deliver the same lookup there.
persist test-gate
The test gate runs the configured testCommand and mirrors its exit code.
The command is split on whitespace and executed directly, not through a shell, so a
hostile config value cannot inject shell syntax. When testCommand is
null, the gate refuses to pass silently. It prints that it was skipped, and
how to turn it on, and exits 0 so an unconfigured repository is not blocked:
$ persist test-gate
Persist OS test gate skipped: the gate is off — set testCommand in .persist/config.json to a one-shot test command (e.g. "pnpm run test:run") or re-run `persist init` to detect one.
With testCommand set, the gate prints the command's output verbatim and
mirrors its exit code:
$ persist test-gate
Persist OS test gate passed: npm run test:run
> orders-api@1.4.0 test:run
> node --test
✔ totals a cart (0.45175ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
…
[exit 0]
$ persist test-gate
Persist OS test gate failed: npm run test:run exited with code 1.
> orders-api@1.4.0 test:run
> node --test
✖ totals a cart (0.836666ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
…
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
24 !== 29
…
[exit 1]
The CI workflow
Init also writes a GitHub Actions workflow that runs the doctor on pull requests and on
pushes to main. It checks out full history so the staleness check can
compare commit times.
name: Persist OS
# Run once per change: on pull requests, and on pushes to the default branch.
# This avoids Doctor running twice for the same PR (branch push + pull_request event).
on:
push:
branches: [main]
pull_request:
jobs:
doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history: doctor's staleness check compares doc vs code commit times,
# which is unmeasurable in a shallow clone.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Validate repository memory
run: npx --yes persist-os@latest doctor
The doctor exits 2 on errors and 1 on warnings, so this job fails on both. If you only want errors to block, wrap the step and treat exit code 1 as a pass.