Praetor pin auto-bump¶
Status: tooling, installed per workstation; adopted 2026-09-28
CI builds praetorctl from the Praetor commit that PRAETOR_REF in
.github/workflows/ci.yml names. The variable carries that name because
Praetor's hook launcher reads PRAETOR_REF under .github/workflows to pick
the engine local hooks run; under its former name, PRAETOR_COMMIT, the
launcher would find no pin and fall back to whatever praetorctl is on PATH.
The maintainer decided on 2026-09-28 that this
pin follows Praetor's main whenever main moves, and that the bump runs on
the maintainer's workstation rather than in GitHub Actions. A pull request
opened with the Actions token starts no CI in this repository; one pushed from
the maintainer's gh account does.
tools/praetor_bump.py performs the bump that pull requests #122 and #130 did
by hand. Three systemd user units run it: when praetorctl is reinstalled, and
once a day.
Install, watch, stop¶
Install from the primary checkout, as yourself and not as root:
The target copies the three units from tools/praetor-bump/ to
~/.config/systemd/user/, points them at this checkout, and enables
aegis-praetor-bump.path and aegis-praetor-bump.timer. It requires gh auth
status to succeed for the account that should open the pull requests, and the
seven state ledgers under .workingdir/ that flavor audit reads (LEDGERS in
the script). praetorctl state init . --if-absent creates the five Markdown
ledgers, and only in a checkout that has none yet; it never repairs a partial
set, so a missing ledger has to be restored explicitly. bugs.meta.json and
questions.meta.json are written by the first praetorctl state bug add and
praetorctl state question add; praetorctl state migrate-bugs also writes
bugs.meta.json from the inline metadata of BUGS.md.
| Question | Answer |
|---|---|
| What did the last run do? | journalctl --user -u aegis-praetor-bump.service |
| When does it run next? | systemctl --user list-timers aegis-praetor-bump.timer |
| Why did a bump fail? | The journal first: every FAIL: and RED: line and any traceback is there. .workingdir/evidence/praetor-bump-<sha7>.log in the primary checkout exists only for a failure after the survey, not for a failed fetch or gh pr list, an unexpected exception or a stopped run |
| Did a rerun pass? | The newest .workingdir/evidence/praetor-bump-<sha7>-green-<UTC time>.log in the primary checkout: every green run writes one with the pin it moved, each gate and its result, the skips and the whole log, and names the RED log of the same head it supersedes. An older RED log stays, but is no longer the newest file for that head |
| Who is told when a run fails? | Whoever the local notifier tells: see Failure notice. Without one, nobody, and the journal line not announced: says so |
| Run it once now | systemctl --user start aegis-praetor-bump.service, or python3 tools/praetor_bump.py |
| Pause it | systemctl --user disable --now aegis-praetor-bump.path aegis-praetor-bump.timer |
| Remove it | make uninstall-praetor-bump removes the units; Clean up removes the rest |
What one run does¶
- Nothing to do? It fetches Praetor's
maininto a bare clone under${XDG_CACHE_HOME:-~/.cache}/aegis-praetor-bump/. When Aegismainalready pins that head, it printsup to dateand exits 0. It also exits 0 when the open bump pull request already names that head and was built on the current Aegismain(theTarget: praetorandBase: aegislines of its body). Whenmainhas moved since, the bump is rebuilt on it: the ruleset requires an up-to-date branch, and a rebase made on the forge is an unsigned commit the signature rule refuses. The shared Praetor checkout next to this one only lends objects to the first clone (git clone --reference-if-able --dissociate); the script never writes to it. - Build. It builds
praetorctlat that commit with the Verification gate's build steps and flags:go mod download,go mod verify,go vet ./..., thengo build -trimpath -buildvcs=false ./cmd/standardsctlwithCGO_ENABLED=0andGOFLAGS=-mod=readonly. A vet finding stops the bump before a binary exists, as it stops the gate.tools/test_praetor_bump.pyholds the workflow to the script's step list. The Go toolchain is the workstation's, under its ownGOTOOLCHAINsetting, whereas CI installs the version Praetor'sgo.moddeclares and setsGOTOOLCHAIN=local. - Adopt. In a fresh worktree of Aegis
mainunder the cache directory it runspraetorctl adopt --force --profile os-imageagainst that commit (without--forceunder--plain-adopt, see Engine refresh). adopt reinstalls the git hooks and rewritesinfo/lefthook.checksum, which lefthook reads before each hook run; both are shared with the primary checkout, so both are put back byte for byte afterwards, also when adopt fails. Since Praetor 963e6ee, adopt itself refreshes the checkpoint evaluator scripts.config/lefthook/scripts/checkpoint.pyandcommon.pywhen they hold an earlier Praetor text, also beside the hand-maintainedlefthook.yml, so the bump copies nothing itself. - Reconcile. Every file in
HAND_MAINTAINEDintools/praetor_bump.pygoes back to its content onmain:lefthook.yml, the evasion interceptor and its six hook registrations, the editor settings and adopt's*.bakfiles..standards-baseline.jsongoes back when only its timestamp and commit changed. Its entries are never recorded by a bump: a finding the new engine reports and the file does not hold fails the gates, and a maintainer fixes it or records it with a reason, as with the ten planted lint cases.AGENTS.mdgoes back as well, followed bypraetorctl compile-context, so only the generated register block and the projections move. Each remaining change is then reverted unlesspraetorctl compile-context --verify,praetorctl auditorpraetorctl flavor audit .fails without it. Changes are tried all together, then one file at a time, then in pairs, because some files only pass together, such as.paperclip/harness.jsonand the register digest in.standards.yaml. - Pin. It moves
PRAETOR_REFand the praetorctl row of the toolchain admission matrix, where the previous pin becomes the "Replaces" value. - Gates. With the new
praetorctlfirst onPATHit runsmake verify-all, which also runsnode tools/markdownlint/verify.mjs(the Documentation Governance check) through itsdocs-lintprerequisite, thenpraetorctl compile-context --verify,praetorctl audit,praetorctl flavor audit ., markdownlint-cli2 at the versionci.ymlpins,yamllint .,flake8 .,black --check,reuse lintandmkdocs build --strict. The portal build runs from a venv ofdocs/requirements.txtin the cache directory; when that venv cannot be made, the pull request body lists the build as a skip, not as a pass. The cargo target directory istarget/under the cache directory. The state ledgers are copied into the worktree, never linked, becauseflavor auditrejects a symlinked.workingdir/. - Publish. On a pass it commits with
git commit --signoff, with the repository's hooks running, and force-pusheschore/praetor-pin-auto. It opens that branch's pull request, or rewrites the title and body of the one already open, so at most one bump pull request exists. Every worktree and binary in the cache is then removed. Every pass, pushed or kept local with--no-publish, writes its own dated evidence file (see the table above). On a failure it writes the failing lines and the whole log to the evidence file named above, keeps the worktree underwork/in the cache directory until the next run that has work, hands one line to the local notifier, exits non-zero, and does not retry the same Praetor and Aegis pair until--retryis passed. A failure before the push pushes nothing. A failure after it, while opening or updating the pull request or queuing its merge, leaves the branch pushed, and the open pull request may still name the previous target. TheRED:journal line and the evidence file'sforge:line say which case happened; check the pull request's title against the branch, or rerun with--retry.
A second run while one is active prints SKIP and exits 0: a lock file in the
cache directory serialises them. Every command runs under its own deadline, and
the service itself stops after four hours. Stopping the service, or Ctrl-C on
a manual run, ends the running command with everything it started and, when
adopt was running, puts the shared git files back before the script exits.
Such a run leaves its journal but no evidence file and no failure marker.
Options¶
| Option | Effect |
|---|---|
--merge |
After a pass, queue gh pr merge --squash --auto, but only when the repository allows auto-merge. It does not today, so the flag only reports that. |
--no-publish |
Commit in the worktree and stop: no push, no pull request. The worktree is kept until the next run that has work. |
--retry |
Run a Praetor and Aegis pair that already failed. |
--plain-adopt |
Run praetorctl adopt without --force, for an engine refresh. A failure is recorded per mode, so a failed forced run does not hold back a plain one. |
--cache-dir, --repo, --praetor-url, --praetor-checkout |
Relocate the cache, the Aegis checkout, the Praetor source and the object donor. |
Engine refresh¶
An engine refresh that the fleet controller announces runs praetorctl adopt
without --force. Its path is the same script with one flag, from the primary
checkout:
Every other step is the timer's: the files in HAND_MAINTAINED go back to
main, and each remaining change is reverted unless a Praetor gate fails
without it. The commit message and the pull request body name the adopt that
ran. Before this mode, the files to revert after a plain adopt were picked by
hand (pull requests #262 and #264), and the list could drift from the timer's.
Failure notice¶
A failed run starts .workingdir/hooks/praetor-bump-notify in the primary
checkout, when that file exists, with one argument: a line naming the old pin,
the target, the Aegis commit, the reason and the evidence file. The file is the
workstation's own and is not committed; what it does with the line (mail, chat,
an inbox file) is its choice. It runs under a 120 second deadline, and its own
failure is a not announced: line in the journal, not a retry.
The notice is sent for a RED run, for a FAIL before the survey finished and
for an unexpected exception. It is not sent again for a pair that already
failed (RED (unchanged)), and nothing is sent when systemd stops the service
at its four-hour limit: that case is in the journal only.
Clean up¶
A failed run keeps its worktree so the failure can be inspected. The next run
that has work, for the same Praetor head with --retry or for a newer one,
first removes every worktree and binary that earlier runs kept, so the cache
holds one run's worktrees at most and the primary checkout's git worktree
list does not grow with each failed head. A successful run removes them as
well, together with the failure records. Past 64 kept worktrees per repository
(MAX_STALE_WORKTREES) a run removes 64, prints how many stay, and the run
after it continues. To delete a kept worktree once it has been inspected, run
from the primary checkout:
cache="${XDG_CACHE_HOME:-$HOME/.cache}/aegis-praetor-bump"
git worktree remove --force "$cache/work/aegis-<sha7>"
make uninstall-praetor-bump stops and removes the three units and nothing
else. To also remove the cache (the bare Praetor clone, the cargo target
directory, the documentation venv, binaries and kept worktrees), run from the
primary checkout afterwards:
cache="${XDG_CACHE_HOME:-$HOME/.cache}/aegis-praetor-bump"
for tree in "$cache"/work/aegis-*; do
[ -d "$tree" ] && git worktree remove --force "$tree"
done
rm -rf "$cache"
git worktree prune
Limits¶
- The linters, the Go toolchain and the Python behind the portal venv are the workstation's, not the versions CI pins; the pull request's own CI re-runs the pinned ones. The toolchain admission matrix lists them under Not yet admitted.
- Reverting in pairs finds two files that only pass together, not three. A larger group stays kept, and the pull request body lists every kept file for review.
- Pairs are tried only while at most 24 files (
MAX_PAIR_FILES) are left after the one-at-a-time pass; past that no pair is tried. Past 256 candidates (MAX_CANDIDATES) the rest are kept without being tried. In both cases the pull request body lists those files as kept. - The debt baseline holds ten entries, the planted cases of the M16 lint
under
ui/concordia-tokens/hiss-lint/plants/(decision D109 in the roadmap). adopt keeps an existing baseline and reports the ratchet's verdict only (reconcileBaselinein the engine at299d3be), so a bump never records one. An engine whose scan finds more fails the bump until the finding is fixed or the baseline is recorded again by hand. - The script commits the pin, the admission row and adopt's output, nothing
else. A change that the new engine requires and the pinned engine refuses
has to enter with the bump itself and is added by hand: the HISS-11
exception of 2026-10-11 (see
CHANGELOG.md) was such a change, because the engine at11ac5d8refuses theexceptionskey of.standards.yaml. cordanaLLM/praetor#1254 asks the engine for a bump command that carries such a change. - The script never bypasses a hook. A bump that the hooks reject is a failure with evidence, not something to force.
- It never merges on its own. Merging stays a maintainer decision unless
auto-merge is enabled on the repository and
--mergeis passed.
The decisions the script makes are covered by tools/test_praetor_bump.py,
which make verify-all runs; the lock and process-group helpers it borrows
from tools/host.py are covered by tools/test_host.py.