Concepts

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.