Files
felhom-agent/scripts/agent_gates.py
T
admin c52d591851
gates / gates (push) Successful in 49s
The decoy suite is a gate of the full run (R-1116); scripts only, no release
scripts/test_gate_decoys.py is now agent_gates.py's `gate-decoys` row: the full run (which CI runs on every
push), not --fast (~20 s in the workspace, 30 cases). Declared in its own COVERS; test_agent_gates.py pins it.
Red-proved: a reuse-refs FACT case flipped to expect rc 0 -> CONVICTED: gate-decoys; restored, green.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:01:25 +02:00

147 lines
7.5 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""agent_gates.py — THE entry point for this repo's gates. Run from the repo root:
python3 scripts/agent_gates.py # every gate
python3 scripts/agent_gates.py --fast # only gates that touch no network and no container
# runtime (what .githooks/pre-push runs)
Gates (all must pass; **non-zero exit on any failure**):
1. reuse-refs every path cited by this repo's REUSE.md still resolves
2. published every `v<semver>` tag has a downloadable package AND a tag tree that serves
the agent's configs (R-115). NEEDS NETWORK, so it is **not** in `--fast` and
the pre-push hook does not run it — a push must not fail because Gitea blinked
or because someone is offline on a train. CI runs the FULL set for exactly this
reason: it is the machine that can afford a network check, and it is the half
that emails when something is wrong.
… gate-decoys the decoy suite (test_gate_decoys.py): every gate here still convicts its decoys
(R-1116). ~20 s in the workspace, so the full run only: not the hook, but CI
runs the full set on every push. Scratch clones and a local fake Gitea only.
WHY THIS FILE EXISTS, WITH ONE GATE (2026-08-02, R-29 leg (b)).
A census of all thirteen gate scripts across the four felhom repos found one clean correlation:
**every check a CLAUDE.md tells a person to run was passing, and two of the four nobody is told
to run were failing** — one since 14 July. This repo was the extreme case: nothing at all ran
against it, and its REUSE.md — 90 cited paths — was checked by no one. This file exists so the
agent is not the one repo with nowhere to put a gate, and so the pre-push hook has the same entry
point in all four repos. It grows when the agent grows a second check.
THE SHARED CHECKER. `reuse_refs_check.py` lives in ONE place — `felhom.eu/scripts/` — and is
invoked here across the workspace at `<repo-root>/../felhom.eu/scripts/`. It is deliberately NOT
copied into this repo: duplicating it would recreate exactly the drift it exists to detect. If the
sibling clone is absent the gate FAILS and prints the path it tried — fail-closed, because a
runner that quietly skips a gate is the inert-seam failure this project has shipped four times.
EXIT CODES. Each gate returns 0 clean / 1 convicted / 2 inconclusive. This runner exits non-zero
if any gate is non-zero, and reports 2 distinctly as INCONCLUSIVE — an undetermined result is
never a pass, but it is not a conviction either.
"""
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SHARED_REUSE = os.path.join(os.path.dirname(ROOT), "felhom.eu", "scripts", "reuse_refs_check.py")
SHARED_INSTRUCTIONS = os.path.join(
os.path.dirname(ROOT), "felhom.eu", "scripts", "instructions_gate.py")
# R-389 — shared, like the two above: it lives in felhom.eu/scripts/ and is never copied.
SHARED_OBSERVATIONS = os.path.join(
os.path.dirname(ROOT), "felhom.eu", "scripts", "observations_gate.py")
# (label, absolute script path, args, fast)
GATES = [
("reuse-refs", SHARED_REUSE, [ROOT], True),
("instructions", SHARED_INSTRUCTIONS, [ROOT], True),
("published", os.path.join(ROOT, "scripts", "check-published-versions.py"), [], False),
# R-273: the tag half of a release. Legs 1-2 need no network, so it runs in --fast too — the
# missing TAG is what actually broke every install, and the pre-push hook is the earliest place
# that can catch it.
("release-complete", os.path.join(ROOT, "scripts", "check-release-complete.py"), [], True),
# R-389 — a REPORT.md observation with no register row behind it. Fast: stdlib file reads.
("observations", SHARED_OBSERVATIONS, [ROOT], True),
# R-1116 (2026-10-17) — the gates' OWN decoy suite. It was in no runner, the shape that let the catalog's
# R-1115 fail unseen for a night. Full run only: ~20 s in the workspace (measured, 30 cases; ~5 s in a
# CI-shaped clone, where the shared gates have fewer siblings to search), too slow for the hook — and CI
# runs the FULL set, so every push is still judged by it. It plants only in scratch clones and asks a fake
# Gitea on 127.0.0.1 (never the network, never the real tree); it does not call this runner (no recursion).
("gate-decoys", os.path.join(ROOT, "scripts", "test_gate_decoys.py"), [], False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
def hooks_armed_note(root):
"""Print a WARNING (never a failure) when this clone's pre-push hook is not switched on.
core.hooksPath is local config and a clone does not carry it, so an unarmed clone is silent
by construction — this is the only place it becomes visible.
"""
try:
val = subprocess.check_output(["git", "config", "--get", "core.hooksPath"],
cwd=root, stderr=subprocess.DEVNULL).decode().strip()
except Exception:
val = ""
norm = val.replace("\\", "/").rstrip("/")
if norm == ".githooks" or norm.endswith("/.githooks"):
return
print("WARNING: this clone is UNARMED — core.hooksPath is %s, so the pre-push hook will not\n"
" run here. Switch it on once with: git config core.hooksPath .githooks"
% (("'" + val + "'") if val else "unset"))
def run_gate(label, path, args):
if not os.path.exists(path):
print("\nFAIL: gate '%s' is MISSING — tried %s" % (label, path))
print(" A missing gate is a failure, never a skip (fail-closed). The reuse-refs")
print(" checker is shared and lives in the felhom.eu sibling clone; it is never copied.")
return 1
print("\n" + "=" * 78)
print("== gate: %s (%s%s)" % (label, os.path.basename(path),
(" " + " ".join(args)) if args else ""))
print("=" * 78, flush=True)
# stream the gate's own output rather than capturing it — its diagnostics are the point.
return subprocess.call([sys.executable, path] + args, cwd=ROOT)
def main(argv):
fast = "--fast" in argv
unknown = [a for a in argv if a != "--fast"]
if unknown:
print("unknown argument(s): %s" % " ".join(unknown))
print("usage: python3 scripts/agent_gates.py [--fast]")
return 2
selected = [g for g in GATES if g[3] or not fast]
skipped = [g[0] for g in GATES if not (g[3] or not fast)]
print("agent_gates — %d gate(s)%s" % (len(selected), " [--fast]" if fast else ""))
if skipped:
print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped))
hooks_armed_note(ROOT)
results = [(label, run_gate(label, path, args)) for label, path, args, _f in selected]
print("\n" + "=" * 78)
print("== summary")
print("=" * 78)
worst = 0
for label, rc in results:
print(" %-18s %-13s (exit %d)" % (label, VERDICT.get(rc, "ERROR"), rc))
if rc != 0:
worst = 1 if rc == 1 or worst == 1 else 2
if worst == 0:
print("\nall agent gates OK")
return 0
convicted = [l for l, rc in results if rc == 1]
undecided = [l for l, rc in results if rc not in (0, 1)]
if convicted:
print("\nCONVICTED: %s" % ", ".join(convicted))
if undecided:
print("UNDETERMINED (never a pass): %s" % ", ".join(undecided))
return worst
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))