From 054e85a2bf3afb8b0b90aff14e5388d023b53711 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 2 Aug 2026 15:22:58 +0200 Subject: [PATCH] gates: one entry point (scripts/agent_gates.py) + pre-push hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A census of all thirteen gate scripts across the four felhom repos on 2026-08-02 found that every check a CLAUDE.md names was passing and two of the four nobody is told to run were failing. This repo was the extreme case: nothing ran against it at all, and its REUSE.md — 90 cited paths — was checked by no one. agent_gates.py exists at ONE gate on purpose, so the agent is not the one repo with nowhere to put a check and so the pre-push hook has the same entry point in all four repos. It grows when the agent grows a second gate. The shared reuse checker stays in felhom.eu/scripts/ and is invoked across the workspace — never copied here; an absent sibling clone FAILS the gate and prints the path tried, which test_agent_gates.py pins by running the entry point from a lone directory with no sibling. .githooks/pre-push runs it with --fast and refuses the push. Per-clone and --no-verify-able, both stated in the hook itself; a manual run WARNS when the clone is unarmed. Tooling only: no Go change, no build, no deploy, no version bump. --- .githooks/pre-push | 47 ++++++++++++++ CLAUDE.md | 13 ++++ scripts/agent_gates.py | 118 ++++++++++++++++++++++++++++++++++++ scripts/test_agent_gates.py | 67 ++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100755 .githooks/pre-push create mode 100644 scripts/agent_gates.py create mode 100644 scripts/test_agent_gates.py diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..438185d --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,47 @@ +#!/bin/sh +# pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.) +# +# Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no +# container runtime, so a push stays a push and never pulls images or starts containers. The slow +# gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and +# the bypass becomes the habit. +# +# BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is +# equally consistent with "gates green" and "hook never fired", so a passing push says so out loud. +# +# HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide: +# * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once: +# git config core.hooksPath .githooks +# Any manual entry-point run WARNS when the clone is unarmed. +# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape +# hatch that cannot be reached is one that gets removed the first time it is +# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT. +# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168. +# +# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd +# is the repo root whether `git push` is issued from the root or from any subdirectory. The +# explicit rev-parse below does not depend on that. +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { + echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2 + exit 1 +} +cd "$root" || exit 1 + +if ! command -v python3 >/dev/null 2>&1; then + echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2 + echo " pass by default. Install python3, or push with --no-verify and say so." >&2 + exit 1 +fi + +echo "pre-push [felhom-agent]: running scripts/agent_gates.py --fast ..." +python3 "scripts/agent_gates.py" --fast +rc=$? +if [ "$rc" -ne 0 ]; then + echo "pre-push [felhom-agent]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2 + echo " 'git push --no-verify' and state that you did in the session report." >&2 +else + echo "pre-push [felhom-agent]: gates OK - push proceeding." +fi +exit $rc diff --git a/CLAUDE.md b/CLAUDE.md index 0e95e7d..fb4229d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,6 +143,19 @@ All shippable work commits **directly to `main`**; `main` equals what is deploye measurement lives in `felhom.eu/CLAUDE.md` "Code quality rules"; it is repeated here because health checks are written in THIS repo and that file does not load in an agent-only session. R-117 spike §6.3. - Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit). +- **Run `python3 scripts/agent_gates.py` from the repo root after ANY change in this repo.** It is + the ONE entry point for this repo's gates. Today it runs one — `reuse_refs_check` over this + repo's `REUSE.md` — and it exists at one gate on purpose: a census on 2026-08-02 found that every + check a `CLAUDE.md` names was passing and two of the four nobody is told to run were failing, and + this repo was the extreme case, with nothing running against it at all and 90 cited paths checked + by no one. It grows when the agent grows a second check. `--fast` selects the gates that touch no + network and no container runtime; today that is all of them. A missing gate is a FAILURE, never a + skip. **The shared `reuse_refs_check.py` lives in `felhom.eu/scripts/` and is never copied here** + — a copy would recreate the drift it detects; an absent sibling clone FAILS the gate. + **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It + is per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run + WARNS when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the + session report when you use it.** Both facts are why CI is still owed (`OPEN-ITEMS.md` R-168). - Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill. - **Logging**: the slog logger fans out to journald (configured level) + the always-DEBUG `applog.Ring` (remote pulls) — English, keys-never-values, durations on outcomes; full rules in diff --git a/scripts/agent_gates.py b/scripts/agent_gates.py new file mode 100644 index 0000000..8b8221f --- /dev/null +++ b/scripts/agent_gates.py @@ -0,0 +1,118 @@ +#!/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 + +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 `/../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") + +# (label, absolute script path, args, fast) +GATES = [ + ("reuse-refs", SHARED_REUSE, [ROOT], True), +] + +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:])) diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py new file mode 100644 index 0000000..69faf11 --- /dev/null +++ b/scripts/test_agent_gates.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Seam test for scripts/agent_gates.py. + +Run from the repo root: python3 scripts/test_agent_gates.py + +WHY THIS EXISTS. An entry point is a seam by definition: a runner that LISTS a gate but never +executes it is inert and fully green, and this project has shipped an inert seam four times. So +the assertion is on the member gate's OWN distinctive stdout — never on the runner's summary +line, which the runner can print without ever calling anything — plus the exit code, which is a +runner's actual effect. + +The second test is the one that matters here: this repo's only gate lives in a SIBLING clone, so +"the sibling is missing" must be a FAILURE and not a quiet skip. +""" +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ENTRY = os.path.join(ROOT, "scripts", "agent_gates.py") + + +class AgentGatesTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + p = subprocess.run([sys.executable, ENTRY, "--fast"], cwd=ROOT, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + cls.rc = p.returncode + cls.out = p.stdout.decode("utf-8", "replace") + + def test_exit_code_is_zero(self): + self.assertEqual(self.rc, 0, self.out) + + def test_member_gate_actually_ran(self): + self.assertIn("cited paths — exact", self.out, + "the reuse-refs gate is listed but its own output never appeared — an inert " + "runner prints the summary without calling anything:\n%s" % self.out) + + def test_missing_shared_checker_is_a_failure_not_a_skip(self): + """Fail-closed. Copy the entry point into a lone directory with no felhom.eu sibling and + confirm it CONVICTS rather than reporting green with nothing run.""" + tmp = tempfile.mkdtemp(prefix="agent-gates-") + try: + lone = os.path.join(tmp, "felhom-agent", "scripts") + os.makedirs(lone) + shutil.copy(ENTRY, os.path.join(lone, "agent_gates.py")) + p = subprocess.run([sys.executable, os.path.join(lone, "agent_gates.py"), "--fast"], + cwd=os.path.dirname(lone), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out = p.stdout.decode("utf-8", "replace") + self.assertNotEqual(p.returncode, 0, out) + self.assertIn("is MISSING — tried", out) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_unknown_argument_is_rejected(self): + p = subprocess.run([sys.executable, ENTRY, "--nope"], cwd=ROOT, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self.assertEqual(p.returncode, 2, p.stdout.decode("utf-8", "replace")) + + +if __name__ == "__main__": + unittest.main(verbosity=2)