Meson + Ninja.
meson setup build
ninja -C build
meson test -C build --suite=fast # interop ABI + UTF-8 paths + shader-compile checks
ninja -C build install # install lib + headers + pkg-configEvery libpelorus parameter that names a file is UTF-8 on every platform (ADR-0149); see docs/api/interop-abi.md.
Options (meson_options.txt):
| Option | Default | Effect |
|---|---|---|
tests |
true | build + register the libpelorus test suite |
shaders |
true | compile the standalone reference .comp shaders to SPIR-V (needs glslang) |
tools |
true | build the libpelorus CLI demonstrators (pelorus_qp_report; not installed) |
The encoder-telemetry text adapters parse dot-decimal numbers through a
call-local C locale. Meson checks newlocale, freelocale and strtod_l at
configure time on POSIX systems, and _create_locale, _free_locale and
_strtod_l on Windows UCRT. A missing symbol stops configuration with the
symbol and platform in the error. Linux probes define _GNU_SOURCE, matching
the feature-test macro used by the adapter translation unit.
Non-Windows builds link libm; Meson carries that dependency into the installed
pkg-config metadata so static consumers receive it through Libs.private.
The Linux core CI job installs locale data and generates de_DE.UTF-8 before
the fast suite. pelorus:enc-telemetry-adapters-locale then changes
LC_NUMERIC to a comma-decimal locale and verifies that both adapters still
parse dot decimals. Local systems without de_DE.UTF-8, de_DE.utf8 or
fr_FR.UTF-8 emit the named skip recorded in
.config/test-skips.json; the ordinary
pelorus:enc-telemetry-adapters test still runs all other adapter checks.
The lavapipe job of .github/workflows/ci.yml runs the real shaders of the
patch stack on Mesa lavapipe, so a runner without a GPU still executes all
ten filters with the Khronos validation layer on. Its result is functional
evidence on software Vulkan, never GPU evidence; the research digest has the
measurements and the reasoning
(research 0228). On a draft pull request
it stops on purpose, like the other hosted gates.
To reproduce it locally, with lvp_icd.json pointing at your lavapipe (the
replay script fetches the shared series, so it needs what
the patch stack section lists):
FFMPEG_REPO=/absolute/path/to/ffmpeg KEEP_DIR=$PWD/kept ffmpeg-patches/test/build-and-run.sh
export VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json PELORUS_VALIDATE=1 \
LD_LIBRARY_PATH=$PWD/kept/lib FFMPEG_BIN=$PWD/kept/ffmpeg
ffmpeg-patches/test/vulkan-lavapipe-guard.sh # device, extensions, features
ffmpeg-patches/test/vulkan-lavapipe-filters.sh # ten filters, zero VUIDs
ffmpeg-patches/test/vulkan-format-matrix.sh
ffmpeg-patches/test/vulkan-lavapipe-report.sh # software_vulkan, functionalKEEP_DIR must be an absolute path that does not exist yet. The guard names
every missing device extension from
ffmpeg-patches/test/vulkan-required-extensions.txt; FFmpeg 9 itself treats
them as optional and would fall back silently.
libpelorus and its fast suite build natively on Windows with the MSYS2 UCRT64
toolchain (MinGW-w64 GCC on the Universal CRT). CI runs this in the windows
job of .github/workflows/ci.yml on a pinned windows-2025 image
(ADR-0149). To reproduce it locally, open
a UCRT64 shell (C:\msys64\ucrt64.exe) and run:
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-meson \
mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-python \
mingw-w64-ucrt-x86_64-glslang mingw-w64-ucrt-x86_64-shaderc
git config --global core.autocrlf false # build the same bytes as Linux
meson setup build && ninja -C build
meson test -C build --suite=fast --print-errorlogs
meson test -C build path-utf8 --verbose # the UTF-8 path contract transcriptFrom PowerShell, set $env:MSYSTEM='UCRT64' and $env:CHERE_INVOKING='1', then
run C:\msys64\usr\bin\bash.exe -lc '<commands>'.
CI installs with update: false: the packages come from the sync database
bundled in the MSYS2 base archive of the pinned setup-msys2 release, not
from a fresh pacman -Sy. A local MSYS2 that is kept up to date can therefore
run newer packages than CI. The job's receipt step prints pacman -Q for the
six packages, so the two can be compared.
Windows-specific behavior of the suite:
path-utf8asserts the Windows half of the UTF-8 path contract. It creates a directory + file named with Latin-1, CJK, and an astral emoji through_wmkdirand an exclusive_wopen, then reads them back throughpel_x265_csv_parse's UTF-8 path. It checks that ill-formed UTF-8 returnsPEL_ERR_INVALIDwitherrno == EILSEQ, that an over-long path returnsPEL_ERR_ABSENTwitherrno == ENAMETOOLONG, and that a\\?\path pastMAX_PATHopens. On POSIX the same test asserts the literalfopenpass-through instead. The test avoids the C11"wx"open mode, which the legacymsvcrt.dllruntime rejects, so it also passes with a non-UCRT MinGW-w64 GCC; UCRT64 remains the toolchain CI tests.- Shader compile checks discard their SPIR-V to
NUL, not/dev/null: a MinGWglslangValidatorwould otherwise write a real\dev\nullfile. build-config-syncrunsscripts/check-build-config.pywithout--self-teston Windows. The self-test's Git fixtures model the Linux-only FFmpeg replay; MSYS2 argument globbing and Python's CRLF text mode break them, and the Linux jobs run the full self-test on every PR.
The FFmpeg patch-stack scripts below remain Linux-only.
Requires libpelorus installed (visible to pkg-config) plus a Vulkan loader +
SPIR-V compiler, and cosign plus an authenticated gh for the shared series
below.
cd ffmpeg-patches
FFMPEG_REPO=/absolute/path/to/ffmpeg ./generate.sh # regenerate patches
FFMPEG_REPO=/absolute/path/to/ffmpeg ./test/build-and-run.sh # apply + build + smokeBoth scripts read the exact tag and peeled commit from root build-config.env,
verify that the qualified tag resolves to that commit, and work in an isolated
run-owned Git worktree. The committed *.patch files are the artifact; edit
the sources under files/ and regenerate. Patch replay supplies its own
ephemeral committer identity and neutralizes caller signing, hooks, and diff
ordering, so the same command works on a clean CI runner and a configured
developer workstation.
build-and-run.sh ends with three GPU smokes: the hevc_nvenc SEI smoke,
the NVDEC → Vulkan → NVENC hop smoke (test/nvenc-cuda-hop-smoke.sh, nine
filter chains on the derived Vulkan device,
ADR-0191) and the Vulkan
forced-keyframe smoke (test/vulkan-forced-keyframe-smoke.sh, 60 Vulkan HEVC
encodes with -force_key_frames 2, a hang fails it,
ADR-0192; its header
lists INPUT, RUNS, OUTPUT_ROOT and --self-test). The first
two need an NVIDIA driver and a Vulkan device; the third needs a Vulkan device
with HEVC video decode and encode. A host that lacks one makes a smoke skip,
and the replay then fails unless NVENC_SMOKE_SKIP (SEI smoke),
NVENC_HOP_SMOKE_SKIP (hop smoke) and KEYFRAME_SMOKE_SKIP (keyframe smoke)
name the reason each smoke prints, so a GPU host whose driver or device went
missing cannot pass by accident
(ADR-0189). On a machine without an
NVIDIA driver or Vulkan video:
NVENC_SMOKE_SKIP="Cannot load libcuda.so.1" \
NVENC_HOP_SMOKE_SKIP="Cannot load libcuda.so.1" \
KEYFRAME_SMOKE_SKIP="no Vulkan device with HEVC video" \
FFMPEG_REPO=/absolute/path/to/ffmpeg ./test/build-and-run.shPelorus's 22 patches do not apply to stock FFmpeg. They apply on top of the
shared FFmpeg fix series, a separate release of
VMAFx/ffmpeg-patches that holds the
FFmpeg fixes every VMAFx project needs
(ADR-0185). You do not download it
by hand: generate.sh, test/build-and-run.sh and
test/qsv-roi-regression.sh call one script, which fetches the pinned
release, verifies it and unpacks it. The tester workflow runs the same script
on the runner, and the image build checks the pin again with --unpack:
scripts/fetch-ffmpeg-series.sh /absolute/path/to/new-dir # prints new-dir/series
scripts/fetch-ffmpeg-series.sh --self-test # the verifier refuses planted defects
scripts/fetch-ffmpeg-series.sh --unpack TARBALL NEW_DIR # image build only: sha256 pin, then unpackWhat you need for the first two: network access, cosign on PATH, and
gh auth login or a GH_TOKEN.
| Variable | Use |
|---|---|
GH_TOKEN |
credential of gh attestation verify; CI passes github.token |
FFMPEG_SERIES_FROM |
directory with the four release files, taken instead of a download and verified the same way |
CURL_MAX_TIME |
download deadline per file in seconds, default 120 |
The script fails, and removes its output, when any of these does not hold:
| Check | Command it runs | What it proves |
|---|---|---|
| sha256 pin | sha256sum against FFMPEG_SERIES_SHA256 |
the tarball is the pinned one, byte for byte |
| signature | cosign verify-blob --bundle SHA256SUMS.sigstore.json, identity release-build.yml@refs/tags/<tag> |
the series repository's release workflow signed the checksums |
| checksums | sha256sum --check --strict SHA256SUMS |
the tarball is the one the signed checksums list |
| provenance | gh attestation verify, source ref and commit pinned |
the tarball was built from FFMPEG_SERIES_COMMIT at that tag on a GitHub-hosted runner |
| base | base.env of the tarball against build-config.env |
the series was made for the pinned FFmpeg release |
The script also refuses a tarball whose members leave its top-level directory
or are not plain files, and a series.txt that lists a missing patch. The fast
test fetch-ffmpeg-series-fail-closed (scripts/test-fetch-ffmpeg-series.py)
plants those defects and the sha256 and base ones without network; the
signature and provenance defects are planted by --self-test, which the
ffmpeg-stack CI job runs.
Apply order, wherever FFmpeg is patched: the series in its own series.txt
order, then ffmpeg-patches/series.txt. To do it by hand, in an FFmpeg
checkout of FFMPEG_COMMIT:
series="$(scripts/fetch-ffmpeg-series.sh "$PWD/../series-release")"
grep -Ev '^(#|$)' "$series/series.txt" | sed "s#^#$series/patches/#" | xargs git -C /path/to/ffmpeg am --3way
grep -Ev '^(#|$)' ffmpeg-patches/series.txt | sed "s#^#$PWD/ffmpeg-patches/#" | xargs git -C /path/to/ffmpeg am --3waybuild-config.env holds the pin: FFMPEG_SERIES_REPO (the series
repository, VMAFx/ffmpeg-patches), FFMPEG_SERIES_TAG, FFMPEG_SERIES_COMMIT
(the commit the tag names) and FFMPEG_SERIES_SHA256 (of the tarball). To
move to another series release, change the last three together from that
release's notes, run scripts/fetch-ffmpeg-series.sh --self-test (it
downloads the release, so it needs the network, cosign and gh too),
regenerate twice and replay. Number 0021 of the Pelorus stack is retired: that
fix is series patch 0001 now.
The sibling vmafx project (the VMAF extension and orchestration layer, pronounced “vee-maf-ex”) ships another FFmpeg patch series. Check it against the Pelorus stack before combining the projects:
FFMPEG_REPO=/absolute/path/to/ffmpeg \
VMAFX_SERIES_DIR=/absolute/path/to/vmafx/ffmpeg-patches \
ffmpeg-patches/test/series-coexistence.shThe input is the ffmpeg-patches/ directory inside a full vmafx checkout: the
checker reads patch names and numbers from its series.txt and compiles the
vendored interop header and source under the sibling core/ directory. A
series-only extraction is insufficient. VMAFX_SERIES_DIR is an unverified,
unpinned input (unlike the pinned, checksum-verified shared series); the
checker records its commit and dirty state in its output. The checker creates
detached worktrees at the FFmpeg tag pinned by build-config.env, fetches and
verifies the shared series, then measures both cumulative orders:
- shared fixes → vmafx → Pelorus (preferred candidate);
- shared fixes → Pelorus → vmafx (reverse measurement).
SHARED_SERIES_DIR may name a Git checkout containing the pinned shared-series
commit to avoid fetching it again; the checker compares base.env,
series.txt, and every applied patch body against FFMPEG_SERIES_COMMIT before
use. BUILD accepts 0 (the default) or 1. With BUILD=1, the checker
builds and installs a private libpelorus, configures Vulkan, all ten Pelorus
filters, and the pelorus_fgs bitstream filter, links FFmpeg, then checks every
registration by its exact name. JOBS defaults to 4 and cannot exceed 4. When
pkg-config cannot resolve libvmaf, that optional reader build is disabled
with a named SKIP: reason checked by test/named-skip.sh. ABI probes use
CC, which defaults to cc.
Every patch first passes git apply --check against the cumulative tree. The
checker is an overlap detector using git apply --check (no three-way merge); it
reports textual hunk collisions across patch series and does not replace
test/build-and-run.sh, which performs full three-way git am replay,
configure, build, link, and runtime smoke testing of the Pelorus stack. A
failure prints the current patch and its conflicting patch or cumulative-tree
predecessor, plus the file and unified-diff hunk headers, before the worktree is
removed. --self-test, wired into the Meson fast suite, plants a same-line
collision and a non-overlap context failure, guards the side-data call flow,
verifies that planted failing CC=false probes and bad JOBS inputs are
rejected, and also proves two non-overlapping controls.
The measurement on 2026-10-15 found neither order supported: both stop at the
configure hunk shared by vmafx's vmaf_pre registration and Pelorus's first
filter registration. Do not combine these revisions by relying on a three-way
auto-resolution. Re-run the command after either series changes; the full
fingerprints and five commonly touched files are in
research 0160.
The checker also verifies the reader contract without depending on a numbered
vmafx patch. It retains the final shared-plus-vmafx cumulative tree when the
combined order is blocked and requires that tree's applied vf_libvmaf.c to
pass AV_FRAME_DATA_SEI_UNREGISTERED to vmaf_set_perceptual_sidedata. Two
compiled probes compare
PelorusSideData, every header and directory offset, the three section layouts
the reader consumes, the magic, ABI major, UUID length, and reader-visible
section identifiers between Pelorus and vmafx. The vmafx reader's ABI minor may
be older because those raw in-filtergraph blob fields are append-only; it may
not be newer than the producer. This does not validate ABI 1.5's zero-free COBS
carrier: the ABI 1.3 vmafx reader recognizes only the raw Pelorus UUID, so the
encoded carrier path is outside this coexistence check.
make verify-nativemake verify-native is the primary product gate. It configures and builds with
Meson/Ninja, runs the fast suite, checks C formatting and clang-tidy, and checks
that CHANGELOG.md matches changelog.d/. Override BUILD_DIR to use another
Meson tree, for example BUILD_DIR=build-asan make verify-native.
A test that exits 77 is a skip, and meson test exits 0 for it. The fast suite
counts a skip as passed only when .config/test-skips.json
lists the test with the exact SKIP: line it prints and the issue or ADR that
keeps it (ADR-0188).
scripts/check-test-skips.py <build>/meson-logs/testlog.json checks this after
make test and in every CI fast-suite run. A new skip, or a changed skip message,
fails until the list names it.
No branch or tag may hold a credential (HISS-12,
ADR-0190). The CI docs job installs the
gitleaks release pinned by version and sha256 in scripts/check-gitleaks.sh,
runs scripts/check-gitleaks.sh --self-test, then scans the full history of
every ref. To run it locally:
GITLEAKS="$(scripts/check-gitleaks.sh --install /absolute/new-dir)" \
scripts/check-gitleaks.sh --self-test
GITLEAKS=/absolute/new-dir/gitleaks scripts/check-gitleaks.shA false positive is exempted by adding its fingerprint
(commit:file:rule:line, printed by the failing scan) to .gitleaksignore.
.gitleaks.toml may only extend the default rules and add rules; an allowlist
there is refused.
The governance targets and the Lefthook jobs run the Praetor engine pinned at
PRAETOR_REF in .github/workflows/standards-gate.yml, whatever praetorctl
or standardsctl is first on PATH. The engine's own launcher,
.config/lefthook/engine.sh, resolves it: it installs the pinned commit with
go install into ${XDG_CACHE_HOME:-$HOME/.cache}/praetor/engine/<PRAETOR_REF>/
on first use (at most PRAETOR_ENGINE_INSTALL_TIMEOUT seconds, default 300) and
runs it from there. make reaches the same launcher through
.config/praetor/engine.mk, which the Makefile includes. Both files are
written by praetorctl adopt; do not edit them
(ADR-0187). The launcher
fails closed, with one line on stderr that names the pin, the cache directory
and the command that fixes it, when the pin is unreadable or declared twice with
different values, or when the cache is empty and Go is missing or the install
fails. It never falls back to PATH. Every worktree and every adopted
repository on the machine that pins the same commit shares one cache directory.
The launcher does not compare the cached binary's version with the pin; the
hosted Standards job does.
To refresh the engine after a pin bump (PRAETOR_REF changes), run
sh .config/lefthook/engine.sh version: the new pin gets a new directory. To
reinstall a damaged cache, delete that pin's directory and rerun.
sh .config/lefthook/engine.sh --print-path prints the resolved binary, and so
does make praetor-engine-path. Call the engine through the launcher for
direct commands too (sh .config/lefthook/engine.sh audit): a praetorctl on
PATH may be another commit and then judges the tree by other rules.
PRAETORCTL=/absolute/path/to/engine overrides the choice for make only; the
hooks always run the pin. The override is not version-checked and prints
nothing. Agent hooks (.claude/settings.json) still call praetorctl by name from
PATH. The PATH coupling is reported upstream as
cordanaLLM/praetor#906.
The documentation targets need Node.js 22.12 or newer; the gate installs its own
locked markdownlint-cli2 0.23.2 dependency tree into a temporary directory.
| Target | Composition and purpose |
|---|---|
make compile-context |
Regenerate cross-tool context and persona projections from their canonical sources |
make compile-context-verify |
Fail if a generated projection has drifted |
make audit |
Verify the pinned manifest/lock, enforce the 51-finding HISS baseline ratchet in the hosted Standards mode (--base, touched-debt delta; AUDIT_BASE overrides the base), and run the engine's policy checks (build warnings, clang-tidy coverage, supply chain, Paperclip pair, hooks, dev container, ruleset). AUDIT_FLAGS=--offline skips the forge reads, as the pre-commit hook does |
make docs-lint |
Lint public Markdown with the locked Praetor configuration and reject links into private scratch directories |
make docs-figures |
Check figure specs and sources; skips with a reason while docs/figures/ has none |
make verify-all |
Run context verification, the audit, verify-native, then docs-lint and docs-figures |
make hooks-install |
Install the tracked Lefthook hooks into the shared Git hooks directory; see Git hooks |
The baseline accepts 51 existing findings in the scanner's supported-file scope
(C, headers, Python, shell, JavaScript/TypeScript, and workflow files):
HISS-01=28 (goto cleanup jumps), HISS-04=21 (functions over 60 lines: 19 in
C, two in Python), and HISS-07=2 (sys.exit calls outside a __main__ entry
point). It is a non-regression ceiling, not a claim of zero debt or whole-tree
source coverage. Fingerprints are keyed by file and line
(Praetor issue 29), so
moving a legacy function can surface it as new.
The local target and the hosted job apply that ceiling differently:
| Where | Command | Fails when |
|---|---|---|
standardsctl audit (plain) |
standardsctl audit |
a finding's fingerprint is not in the baseline; the total exceeds the baseline; any finding, baselined or not, sits in a file with uncommitted changes (Praetor's touched-file rule) |
make audit |
standardsctl audit --base "$(AUDIT_BASE)" --touched-debt-delta-reason "$(AUDIT_DEBT_REASON)", base origin/master unless AUDIT_BASE is set |
as hosted step 1 |
| Hosted, step 1 | standardsctl audit --base <target> --touched-debt-delta-reason <reason> |
the committed baseline records more findings than the baseline on the target (growth guard); a file the branch touches has more findings of some rule than the committed baseline records; a finding in an untouched file is not in the baseline; the total exceeds the baseline |
| Hosted, step 2 | standardsctl baseline --verify |
a current finding is not recorded at its current line; the total exceeds the baseline |
The target is origin/<base branch> for a pull request and the replaced
commit for a push to master. Without the debt-delta reason, --base would
revoke every baselined finding in a touched file; that zero-debt mode stays
deferred by ADR-0145. Step 1 accepts a touched file whose findings only moved
lines, so step 2 makes the branch re-record the baseline for them. A stale
fingerprint on master would otherwise fail the next pull request that does
not touch that file.
Existing findings may shrink. A deliberate increase recorded with
standardsctl baseline --record --allow-increase --reason=<why> passes the
growth guard with a [WARN] line that prints the reason, so the increase stays
visible in the job log and in the baseline diff. Any change to the baseline
total also changes the managed README block, which the audit compares with the
baseline; refresh it with the adoption command in the ADR. To run the hosted
steps locally:
CI=true standardsctl audit --base origin/master \
--touched-debt-delta-reason "local run of the hosted ratchet"
standardsctl baseline --verifyTwo hosted workflows run on every pull request. Standards installs Praetor at
the commit in ADR-0168, prints
the module version Go recorded for it, verifies generated contexts, and runs
the two ratchet steps above. The live master protection, a classic branch
rule, requires Standards and the core, ffmpeg-stack, and docs jobs; the
committed ruleset requires all nine pull-request checks once it is applied
(Branch ruleset). Praetor Documentation Governance is Praetor's locked
workflow for the docs:seo-portal facet: it runs the same Markdown and figure
checks as make docs-lint docs-figures. On a draft pull request its first
step, Stop on a draft pull request, fails on purpose and the checks do not
run; they run once the pull request is marked ready for review. praetorctl audit compares that
workflow, tools/markdownlint/, tools/figures/, the documentation block in
the Makefile, and the managed block at the end of .gitattributes byte for
byte with the pinned engine's assets. Refresh them only with the adoption
command in the ADR, never by hand.
Since ADR-0153 the manifest declines no adoption step. A local audit therefore also needs two things CI does not check:
- An installed pre-commit hook. The audit requires Lefthook's
pre-commithook in the Git hooks directory that every linked worktree shares; runmake hooks-installonce (Git hooks). CI installs no hooks, and the audit skips that check whenCIis set. - Live branch protection that matches the ruleset. With a forge token
(
GITHUB_TOKEN,GH_TOKEN, or aghlogin) and anoriginthat namesVMAFx/pelorus, the audit compares the protection GitHub enforces onmasterwith.github/rulesets/main.json. Until the ruleset is applied, that comparison fails (Branch ruleset). The hosted job has no token and reports the check as not made.AUDIT_FLAGS=--offline(or--offline) skips it locally.
Do not create a placeholder hook file, weaken the manifest, or run remote sync to get past a failing check.
Two audit checks read lists that ordinary code changes can break:
- Build warnings (HISS-10). Every
meson setupin.github/workflows/passes--werror. The audit reads only the command line, not thewerror=trueinmeson.build'sdefault_options, so a hosted lane without the flag fails even though Meson would build it with warnings as errors.scripts/check-build-config.pyalso pins the Windows job'smeson setup --werror build && ninja -C build. - clang-tidy coverage.
.standards.yamldeclares one clang-tidy lane,libpelorus, whose units are listed in.config/clang-tidy/lane-files.txt: every C file the Meson build compiles (libpelorus/src,libpelorus/test,tools, and the twoffmpeg-patches/testprograms Meson builds against a stub header).make tidyand thecorejob's clang-tidy step read that same list, so the lane the audit judges is the lane that runs. A tracked C unit that no lane reads fails the audit unless the top-levelexceptionslist names it with a reason and an expiry at most 90 days ahead. Add a new Meson unit to the list in the same change. The 13ffmpeg-patchesunits that build only inside an FFmpeg tree have oneclang-tidy-coverageexception each, expiring 2027-01-06; #94 tracks the FFmpeg-tree lane that replaces them.
Triage failures by boundary:
- A
verify-nativefailure belongs to Pelorus and must be fixed here. - Context drift means a canonical source changed without regeneration. Edit
AGENTS.mdor.agents/agents/*.md, then runmake compile-context; never patch a generated projection directly. - A
docs-lintdiagnostic names a file, line, and markdownlint rule. Fix the Markdown; the rule set is locked by the engine. - Any audit failure is a governance regression in this repository. Fix it,
or, where the check allows one, declare a dated entry in the
exceptionslist of.standards.yamlin a reviewed change.
Canonical context sources are AGENTS.md and .agents/agents/*.md. Generated
files that compile-context owns include root CLAUDE.md,
.cursor/rules/hiss-invariants.mdc, .github/copilot-instructions.md,
.windsurfrules, .gemini/GEMINI.md, .codex/rules.md,
and the Markdown persona projections under .claude/, .codex/, .github/,
and .gemini/. Direct edits to those projections are overwritten or rejected
by verification. The text register block between the
<!-- praetor:register:start --> markers in AGENTS.md is rendered from the
manifest by compile-context; do not edit it by hand.
The engine installs three text-register skills (caveman, social-text,
adhd-format) under .agents/skills/; compile-context copies them to
.claude/skills/. compile-context --verify and the audit also lint the
nested ffmpeg-patches/AGENTS.md, libpelorus/AGENTS.md, and
tools/AGENTS.md in the Caveman register: at most 2.0 articles per 100 prose
words and at most 30 words per sentence. Check one file with
praetorctl caveman check --kind=context <file>.
.paperclip/harness.json and .paperclip/rules.md are a separate generated
pair from standardsctl paperclip harness; compile-context does not own them.
Regenerating that pair requires an explicit consumer review because the pinned
generator does not yet honor every Pelorus branch, language, and policy choice
(Praetor issue 321). The audit lints the harness's operating-contract and
invariant strings in the internal (Caveman) register. register.sources in
.standards.yaml records how many strings it read and their digest, so an
edited string also needs new pins. Since the ADR-0168 pin the audit also fails
when rules.md is not the engine's rendering of harness.json. adopt refuses to re-bind that drift, even
with --force. Stage the edit, run
standardsctl caveman check --configured-sources --root=., and copy the
actual digest (and count, if it changed) that it reports into
register.sources.
The profile and facets in .standards.yaml declare more controls than Pelorus
executes. The audit verifies the lock, the baseline, generated surfaces, the
documentation gate, build warnings, clang-tidy coverage, and the release
provenance, signature, and SBOM (Release). It does not check the
controls below, and no workflow implements them; they are declared only
(ADR-0145).
| Declared by | Control | Current state |
|---|---|---|
native-gpu-systems, security:high, api:public-contract |
Signed commits, stale-review dismissal, review-thread resolution, nine required checks | Committed in .github/rulesets/main.json, not applied yet; live master protection requires linear history and four checks, no signatures. Approving reviews: zero under review_mode: single_maintainer (Branch ruleset) |
native-gpu-systems |
semgrep, cppcheck, clippy |
Not run; Pelorus has no Rust for clippy |
security:high |
gitleaks, trivy |
Not run; .gitleaks.toml only configures a manual gitleaks run |
api:public-contract |
buf, spectral, OpenAPI drift, Migration: footer check |
Not run; Pelorus has no protobuf or OpenAPI surface. The append-only C ABI is guarded by the interop conformance fixture and review |
docs:seo-portal |
Schema.org JSON-LD, sitemap, robots.txt, Core Web Vitals |
Not applicable; Pelorus builds no documentation site. The facet's locked Markdown and figure gate does run |
native-gpu-systems |
No per-frame dynamic allocation | HISS-03, by C review only |
The declared linters that do run are clang-tidy (the core job and
make verify-native, over the units in .config/clang-tidy/lane-files.txt)
and markdownlint (the documentation gate). HISS-04
complexity is split the same way. For C the audit ratchets only the 60-line
function cap. Its cyclomatic, cognitive, and statement measurement covers Go
sources only, so the effective limits of 10, 12, and 40 are not machine-checked
here. The .clang-tidy function-size thresholds (75 lines, 120 statements, 20
branches) are advisory and cover the clang-tidy lane only. Those limits are reviewer
checks.
The manifest no longer declines agent-hooks
(ADR-0145). Adoption registers
the engine's pre-tool interceptor in each agent client's tracked hook file,
beside Pelorus's own hooks:
| File | Client event and matcher | Command | Timeout |
|---|---|---|---|
.claude/settings.json |
PreToolUse, Bash (the group that also runs block-unsafe-bash.sh) |
praetorctl hook claude pre-tool |
15 s |
.codex/hooks.json |
PreToolUse, Bash (same group as the Codex block-unsafe-bash.sh) |
praetorctl hook codex pre-tool |
15 s |
.gemini/settings.json |
BeforeTool, ^run_shell_command$ |
praetorctl hook gemini pre-tool |
15000 ms |
The hook reads the client's JSON payload on stdin, finds the repository from
the payload's cwd (or its own working directory), and judges only the shell
command. It denies with exit 2 and a [BLOCKED BY HISS] ... line on stderr
when the command skips Git hooks (--no-verify and its abbreviations, -n on
git commit or git am), disables Lefthook, sets core.hooksPath, writes
into .git/hooks, or runs lefthook uninstall. Any other command passes with
exit 0 and no output. Outside a checkout that holds .standards.yaml it skips
with exit 0 and a reason on stderr. Praetor's docs/guides/agent-hooks.md at
the pinned commit lists every rule. The hook judges agent tool calls only;
commands you type in your own terminal never pass through it.
Everyone who runs Claude Code, Codex, or Gemini CLI in this repository needs
an executable named praetorctl on PATH. The pinned go install builds
standardsctl, so copy it under the name the hooks call, then check it with a
sample payload from the repository root:
go install github.com/cordanaLLM/praetor/cmd/standardsctl@2d23b292417386fe8259da29690266ce331062c6
cp "$(go env GOPATH)/bin/standardsctl" "$(go env GOPATH)/bin/praetorctl" # Windows: standardsctl.exe to praetorctl.exe
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"ls"},"hook_event_name":"PreToolUse"}' \
| praetorctl hook claude pre-tool; echo "exit=$?" # expect exit=0, no outputWhen praetorctl is missing, the shell answers 127 (bash) or 1 (cmd and
PowerShell). Claude Code and Gemini CLI treat every exit other than 2 as a
non-blocking error: they show it and run the command without the Praetor
policy. Codex documents only exit 0 and exit 2, so its handling of 127 is
unverified. Pelorus's own block-unsafe-bash hook runs either way. Install the
engine before starting an agent session rather than relying on that fallback.
On Windows, Claude Code runs hook commands through Git Bash, which the
.claude/hooks/*.sh scripts need anyway; Gemini CLI runs them through
PowerShell and appends exit $LASTEXITCODE itself. The exit codes above hold
under Git Bash, cmd /c, a direct PowerShell call, and Gemini's PowerShell
form (research digest 0145).
A wrapper that ends with the hook as the last statement of
powershell -Command without exit $LASTEXITCODE would turn a deny (2) into
1, which the clients do not block on.
Adoption owns the three registrations. It adds a missing row and leaves a file that already has one byte for byte as it is, so re-running the adoption command in the ADR is how to restore a row. Keep Pelorus's own hook entries where they are; the merge preserves them.
The manifest adopts git-hooks
(ADR-0153). lefthook.yml holds the
jobs the pinned engine renders plus four Pelorus jobs, and
.config/lefthook/ and .config/agent/ hold the checkpoint scripts, the
checkpoint policy, and the agent interceptor that come with them. Install the
hooks once per clone, in your own terminal:
make hooks-install # runs `lefthook install`| Hook | Jobs |
|---|---|
| pre-commit | reuse-lint (reuse lint; skipped where reuse is not installed, fails on a reuse other than 6.x), context-check (compile-context --verify), hiss-audit (audit --offline), pelorus-audit (make audit AUDIT_FLAGS=--offline) |
| commit-msg | message-policy (forge check-message: Conventional Commits subject, Migration: footer on a breaking change), dco-signoff (Signed-off-by trailer) |
| post-commit | state-sync, dedupe-cadence (governance bookkeeping in the git-ignored .workingdir/) |
| pre-push | flavor-audit, audit (reads the forge), gate (gate run --admit-unsupported), verify-all (make verify-all) |
pelorus-audit, message-policy, dco-signoff and verify-all are the
Pelorus additions. The pre-push
gate job needs a clean working tree, and admits a Meson repository without
signing a receipt. The pre-push audit and verify-all read the forge, so
they fail while live master protection differs from the committed ruleset
(Branch ruleset). Installed hooks need praetorctl (or
standardsctl), lefthook 2.1.14 or newer, make, Python 3.10 or newer, and,
for a push, the native and documentation toolchains of make verify-all.
Every commit needs a Conventional Commits subject, a Signed-off-by trailer
(git commit -s), and, when breaking (! in the subject), a Migration:
footer with before/after snippets (HISS-14). The hooks are opt-in, so CI is the
authority: the Standards workflow runs standardsctl forge check-commits
and a DCO check over every commit of a pull request, from the base branch to
the pull request head.
| Where | Check | Command |
|---|---|---|
commit-msg hook |
subject + Migration: footer |
praetorctl forge check-message <file> |
commit-msg hook |
DCO trailer | grep for Signed-off-by: Name <email> in the message |
standards-gate.yml, pull requests |
subject + Migration: footer |
standardsctl forge check-commits --base origin/<base> --head <pr head> |
standards-gate.yml, pull requests |
DCO trailer | git rev-list loop over the same range |
Check a range locally with praetorctl forge check-commits --base origin/master.
lefthook.yml sets no_auto_install: true. Without it, lefthook run
re-installs the hooks whenever the file differs from the one installed, from
whichever worktree commits first. Run make hooks-install again after
lefthook.yml changes. Because adoption installs hooks only for an unedited
rendering, it does not install them here either.
Linked worktrees share the repository's Git hooks directory, so installing or removing hooks is repository-wide. Remove them with Lefthook's verified removal command, typed in your own terminal (the agent hook denies it to agents):
lefthook uninstall.github/rulesets/main.json is the ruleset praetor-main-protection that the
engine renders for master and lts-*. .standards.yaml declares
overrides.branch_protection.review_mode: single_maintainer (Praetor's
docs/guides/review-policy.md), so the rendering requires:
| Rule | Value |
|---|---|
| Pull request | 0 approving reviews, no code-owner review, stale reviews dismissed, review threads resolved |
| Required status checks (strict) | the five ci.yml jobs, Documentation Governance, REUSE lint, Standards & Invariant Verification Gate |
| History | signed commits, linear history, no force push, no deletion |
| Bypass | repository admin role, pull requests only |
The engine derives the checks from the workflows: a job is required when every
pull request reports it. The Dev container image workflow is path-filtered,
so it adds none. The audit re-renders the file and fails on any drift; never
edit it by hand. A change to a job name or a trigger changes the rendering, so
regenerate the file with the adoption command in ADR-0153 in the same change.
The ruleset is committed, not applied. After the change that adds it lands,
the maintainer applies it from an up-to-date master:
praetorctl plan --remote # preview; reads GITHUB_TOKEN or GH_TOKEN
praetorctl sync --remote # writes the ruleset, labels and repository metadatasync --remote writes rulesets only: the first run creates
praetor-main-protection with its bypass actor, a later run keeps the checks
it finds there, and each run prints a review setting it lowers. The classic
master rule stays until it is removed by hand. The repository merges by squash only; GitHub signs a squash
commit made in the web interface, which the signed-commit rule needs. Until
the ruleset is applied, an audit with a forge token fails like this:
[FAIL] Live branch protection of master on GitHub does not match the declared policy (protected by branch protection):
Dismiss stale reviews: declared required, live not enforced
Signed commits: declared required, live not enforced
Required status checks: declared 8, live 4 of 8 required; missing: ...
.devcontainer/ holds the bundle the pinned engine renders:
devcontainer.json, Dockerfile.praetor, and six praetor-source.*.b64
parts that build praetorctl into the image. The audit compares them with the
rendering byte for byte; regenerate them with
praetorctl devcontainer --source-root <Praetor checkout at the pin> --force,
never by hand. The rendered image is the Pelorus toolchain base (below) plus
praetorctl and the catalog's common-utils, nix, and rust features, so
its postCreateCommand (make verify-all) finds Meson, Ninja, clang, glslc,
Vulkan headers, and Node.js.
.devcontainer/base/Containerfile is that toolchain base. It starts from the
plain ubuntu:26.04 image, pinned by digest (ADR-0179),
not from Microsoft's devcontainers base, which ships a source-built GPL git.
It adds the vscode user with sudo, the packages the CI jobs install (git is
Ubuntu's package), Node.js 24, actionlint, and Lefthook, each download checked
against its published SHA-256. The Dev container image workflow
(.github/workflows/devcontainer-image.yml) handles it:
| Trigger | Job | Result |
|---|---|---|
Pull request touching .devcontainer/base/** or the workflow |
build |
builds the image, pushes nothing; on a draft its first step fails by design |
Push to master touching the same paths, or manual dispatch |
publish |
pushes ghcr.io/vmafx/pelorus-dev tagged with the commit SHA and latest, reads the digest back, attests SLSA build provenance for it |
Only publish holds packages: write, with id-token, attestations, and
artifact-metadata for the attestation. Check a published digest with:
gh attestation verify oci://ghcr.io/vmafx/pelorus-dev@sha256:<digest> --repo VMAFx/pelorusThe bundle builds on that image. .devcontainer/Dockerfile.praetor and the
baseImage field in devcontainer.json name
ghcr.io/vmafx/pelorus-dev@sha256:92599aa475c90ef9609d28979f6ee56d85fd995f52ca0af94c27dd4205040498,
the image publish built from 8e6a41e, the last commit to change
.devcontainer/base/Containerfile. The reference is a digest, never a tag; the
renderer rejects a tag-only --base-image, and the audit's DevContainer bundle check fails on any drift from the rendering. The package is public: the
digest resolves without a login.
Refresh the pin whenever .devcontainer/base/Containerfile changes:
- Merge the Containerfile change to
master; wait forpublishto push. - Read the new digest from the
publishrun summary, or fromgh api 'orgs/VMAFx/packages/container/pelorus-dev/versions?per_page=5'(the version tagged with the commit SHA). - Verify it:
gh attestation verify oci://ghcr.io/vmafx/pelorus-dev@sha256:<digest> --repo VMAFx/pelorus. - Re-render, never hand-edit:
praetorctl devcontainer --force --source-root <Praetor checkout at the pin> --base-image ghcr.io/vmafx/pelorus-dev@sha256:<digest>. - Run
praetorctl audit --offlineand confirmDevContainer bundlereports in sync.
The image is public, so it carries the same licence record as the tester image.
.devcontainer/base/licensing.json lists every component outside the Ubuntu
packages (Node.js, actionlint, Lefthook, Canonical's pebble, the gate itself)
with its SPDX licence, paths, source and licence texts. Ubuntu files are recorded
by package ownership, and each package needs a copyright file and an archive
component of main or universe. The licence stage of the Containerfile runs
tools/tester/licensing.py on the finished tree and fails the build on a file
that no package, component or expiring ignore rule owns, an unknown or
non-redistributable licence, or a missing licence text. A tool added under
/usr/local without a record entry therefore fails the pull request build.
Inside the image:
| Path | Content |
|---|---|
/usr/share/licenses/pelorus-dev/THIRD_PARTY_NOTICES.txt |
generated notices: repository and commit, every component with its licence, every Ubuntu package with its version |
/usr/local/share/licenses/{node,actionlint,lefthook}/ |
licence text of each tool installed outside the package system |
/usr/local/share/pelorus-dev/licensing.{py,json} |
the gate and the record; run python3 -I -B licensing.py check --root / --record licensing.json --commit <40-digit commit> to repeat the build check |
The image sets no org.opencontainers.image.licenses label; the label
dev.vmafx.pelorus.notices names the notices file. After a change to what the
image installs, edit licensing.json in the same commit and check it without
an image: python3 -I -B tools/tester/licensing.py record --record .devcontainer/base/licensing.json.
To try the toolchain base without the registry, build it locally from the
repository root (the licence gate lives in tools/tester/):
docker build --file .devcontainer/base/Containerfile \
--build-arg PELORUS_COMMIT="$(git rev-parse HEAD)" --tag pelorus-dev .Neither image passes a GPU through: the Vulkan shaders compile, the GPU tests do not run.
renovate.json drives two kinds of machine updates, and the build-config
checker (meson test -C build --suite=fast, test build-config-sync) fails if
either would leave a copied value behind:
- FFmpeg pin — one regex manager updates
FFMPEG_TAGandFFMPEG_COMMITinbuild-config.envtogether (ADR-0144). The shared series pin below it (FFMPEG_SERIES_*) is bumped by hand: Renovate cannot derive the tarball sha256 or the tagged commit, andscripts/fetch-ffmpeg-series.shrefuses a series made for another FFmpeg release, so an FFmpeg bump needs a series release on the new base first (ADR-0185). - actionlint Go toolchain — the docs job's
actions/setup-gostep (go-version: '<major>.<minor>.x') is bumped by Renovate's built-in github-actions handling as dependencygo(datasourcegithub-releases, packageactions/go-versions,npmversioning). The checker's own expectation,ACTIONLINT_GO_VERSIONinscripts/check-build-config.py, is covered by a second regex manager with exactly those templates, so both edits share onerenovate/go-<major>.xbranch and PR. The checker verifies that manager's templates and that itsmatchStringsentry captures the literal exactly once. Removing or breaking that manager fails the fast suite. The checker does not evaluate otherrenovate.jsonsettings (ignorePaths,enabledManagers,packageRules,extendspresets); if one of them splits or drops the bump, the Renovate PR that edits only one site failsbuild-config-sync, and that red PR is the detection point (ADR-0152). The step name carries no version for the same reason (ADR-0151).
The Windows job's msys2/setup-msys2 action is bumped by Renovate's
github-actions manager like every other action (digest plus # vX.Y.Z
comment). The checker holds no copy of that pin. It checks only its shape:
exactly one uses: msys2/setup-msys2@<40-hex digest> # vX.Y.Z line, before
the first MSYS2 step. So such a bump changes ci.yml alone and stays green
(ADR-0149).
The checker decides which managers cover a file the way Renovate does, through
managerFilePatterns (see
research digest 0151). An
entry is either a /regex/ or /regex/i, optionally negated with !, or a
minimatch glob with dot and nocase. Globs may use *, ?, whole-segment
**, {a,b} and a leading !. The checker reports any other glob syntax as an
error rather than guess at it: character classes, extglobs, escapes, ranges,
and braces that span /. Use a /regex/ entry for anything more specific.
The docs job's actions/setup-go pin and the actionlint release follow the same
rule as setup-msys2: the checker holds no copy of either value and checks only
the shape. The job needs exactly one
uses: actions/setup-go@<40-hex digest> # vX.Y.Z line and exactly one
run: go run github.com/rhysd/actionlint/cmd/actionlint@vX.Y.Z line, with no
floating tag or branch ref. Renovate's github-actions manager bumps the
setup-go digest. A third regex manager in renovate.json covers only
.github/workflows/ci.yml, reads the actionlint tag as currentValue, and
looks it up as dependency github.com/rhysd/actionlint in the go datasource.
The checker requires exactly one regex manager for ci.yml, with those two
templates and a matchStrings entry that matches ci.yml once. The manager
for the checker file stays the Go-toolchain one above
(ADR-0170,
research digest 0170).
When bumping by hand, change the go-version in .github/workflows/ci.yml and
ACTIONLINT_GO_VERSION together, then run
python3 scripts/check-build-config.py --self-test and
go run github.com/rhysd/actionlint/cmd/actionlint@<the tag in ci.yml>.
SemVer tags v<major>.<minor>.<patch> and release candidates
v<major>.<minor>.<patch>-rc.<N> (procedure). The interop ABI is append-only from
v0.1.0 (PELORUS_ABI_MINOR bumps on additions). The shared conformance fixture
must pass in both Pelorus and vmafx before a release that touches the ABI.
The Release workflow (.github/workflows/release.yml) runs three jobs in
order (ADR-0169):
cicalls the wholeCIworkflow (uses: ./.github/workflows/ci.yml: core, FFmpeg patch-stack regenerate, replay, link and smoke, the lavapipe lane, sanitizers, Windows, docs), so a tagged commit with a broken stack never builds a release. The call runs with the caller'sgithubcontext, andci.ymlkeys its concurrency group on the workflow name so it cannot cancel a push or pull-request run.build(needs: ci) calls the reusable.github/workflows/release-build.yml, whose only trigger isworkflow_call. Its one job configures withmeson setup --werror build, builds withmake build, runs the fast suite, checks the rendered changelog, extracts the release notes from the## [X.Y.Z]section for the meson version (scripts/release/extract-release-notes.sh; a missing or empty section fails the build), and packagespelorus-ffmpeg-patches-<tag>.tar.gz(series.txt, the README, the numbered patches,files/, the LGPL-2.1-or-later and EUPL-1.2 texts fromLICENSES/, and a generatedNOTICE, licensing). The release notes extractor unwraps paragraphs and list items, orders### Highlightsfirst and### Maintenancelast, and rewrites root-relative documentation links and reference definitions into absolute GitHub blob URLs pinned tov<VERSION>at<server>/<owner>/<repo>/blob/<tag>/<path>(via--repoor$GITHUB_REPOSITORY, with$GITHUB_SERVER_URLdefaulting tohttps://github.com), refusing link targets that escape the repository root (../). TheNOTICEnames the shared FFmpeg fix series release the stack needs first (tag, commit, tarball sha256); the archive does not contain that series. It then writes an SPDX JSON SBOM of that archive, writesSHA256SUMSover the archive and the SBOM, attests SLSA v1.0 build provenance for both withactions/attest-build-provenance, signsSHA256SUMSkeyless with cosign intoSHA256SUMS.sigstore.json, verifies that signature against its own workflow identity, and uploads the files as one workflow artefact. No step downloads an artefact or restores a cache before the attestation, so the attestation covers only what this job built. GitHub signs it with the reusable workflow's identity, isolated from the caller: SLSA Build Level 3, whichpraetorctl auditmeasures (HISS-11).publish(needs: build) runs only on av*tag push. It downloads the artefact, re-checksSHA256SUMS, and runsgh release createwith the archive, the SBOM, the provenance bundle,SHA256SUMS, andSHA256SUMS.sigstore.json. It is the only job withcontents: write.
A tag push first checks the tag shape and the rc numbering
(scripts/release/verify-release.py tag, release candidates),
then asserts that ${GITHUB_REF_NAME#v} equals the version meson
reports (meson introspect --projectinfo build; meson already fails
configuration if pelorus.h disagrees), that pelorus.h carries the same
number for number, and fails with an ::error:: line on a mismatch. A
vX.Y.Z-rc.N tag additionally runs the candidate legs check and publishes a
prerelease that is never latest. A manual workflow_dispatch is a non-publishing rehearsal: it
runs ci and build, including the attestation and the signature, skips only
the tag assertion, and never runs publish. Review the tag and the
## [X.Y.Z] notes before pushing a tag: a manual dispatch is not a
substitute for the tag event and never publishes on its own.
scripts/check-build-config.py enforces this shape: the CI call and the
needs chain, workflow_call as the only trigger of release-build.yml, the
tag step, the build-before-attestation step order, no artefact download or
cache in the release build, the SBOM action's upload switches, the signer
identity of the verification, the tag-only publish job, its asset list, and
contents: write on publish alone. Its self-test rejects a mutation of each.
Each release carries five files besides the notes. Verify them with the GitHub
CLI, cosign v3 or newer, and sha256sum:
TAG=v0.2.3
gh release download "$TAG" --repo VMAFx/pelorus
# SLSA provenance: signed by the reusable release build, built from the tag
gh attestation verify "pelorus-ffmpeg-patches-$TAG.tar.gz" -R VMAFx/pelorus \
--signer-workflow VMAFx/pelorus/.github/workflows/release-build.yml \
--source-ref "refs/tags/$TAG"
# cosign signature over the checksums
cosign verify-blob \
--certificate-identity "https://github.com/VMAFx/pelorus/.github/workflows/release-build.yml@refs/tags/$TAG" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--bundle SHA256SUMS.sigstore.json \
SHA256SUMS
# the archive and the SBOM match the signed checksums
sha256sum -c SHA256SUMSA release candidate carries a sixth file, CANDIDATE_LEGS.json, which
SHA256SUMS covers like the SBOM.
gh attestation verify fetches the attestation from GitHub; pass
--bundle "pelorus-ffmpeg-patches-$TAG.provenance.sigstore.json" to verify
against the bundle attached to the release instead. Because a reusable
workflow signs the attestation, --signer-workflow names
release-build.yml, not release.yml. The SBOM,
pelorus-ffmpeg-patches-<tag>.spdx.json, has no signature of its own:
SHA256SUMS covers it, and the provenance names it as a subject.