#!/usr/bin/env python3 # -*- coding: utf-8 -*- """repo_gates.py — THE entry point for this repo's gates. Run from the repo root: python3 scripts/repo_gates.py # every gate python3 scripts/repo_gates.py --fast # only gates that touch no network and no container # runtime (what .githooks/pre-push runs) Gates, in order (all must pass; **non-zero exit on any failure**): 1. site website HTML: BOM, emoji, nav/footer, analytics, CDN, tokens, cache-busting 2. hostinstall felhom-host-install.sh's five drill-swept invariants (+ R-94's absent-version) 3. hub-confirm no native confirm()/prompt() in hub templates 4. manifest-bearer no bearer-shaped literal anywhere in manifests/ 5. reuse-refs every path cited by this repo's REUSE.md still resolves 6. instructions CLAUDE.md length/versions/TEMPORARY, rule-file scoping, workspace-copy identity 7. golden-currency a released controller has a golden carrying it (R-242) 8. wire-contract every emitted field is decodable by its receiver (G-1) 9. hub-copy the hub's customer-facing words, against the retired-name list (R-324) 10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341) 11. one-register open work living outside OPEN-ITEMS.md (R-369) 12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405) 13. observations a REPORT.md observation with no register row behind it (R-389) **THE `GATES` TABLE BELOW IS THE LIST; THIS IS A POINTER TO IT.** It drifted once already — it read eleven while thirteen were registered, from 2026-08-24 until 2026-09-01, so `one-register` and `closed-register` ran on every push while being documented nowhere (R-418). Add a gate here in the same commit, or delete this list rather than let it lie. SCOPE (R-404, 2026-09-01). `--scope=docs` marks a push whose whole range touches documents. It changes ONE thing: a CONVICTION by a gate whose fifth `exemptible` field is True — today `golden-currency`, and only it — prints as ADVISORY and does not refuse the push. The gate still RUNS and still CONVICTS; its verdict, exit codes and wording are untouched. Every other gate refuses every push, in every scope. An unscoped run behaves exactly as before. WHY 11 IS HERE (2026-08-24, R-389). On 2026-08-23 a session measured on live hardware that only the FIRST broken app per hour reaches the operator — the notification cooldown keys on the event type, not on the app. It was real, reproducible, and written under `## Observations` in `REPORT.md`. It was written NOWHERE ELSE. `REPORT.md` is overwritten every session by this project's own convention, so the finding had a lifetime of exactly one session and had to be re-derived the next day. That is gate 10's shape one surface over — a commitment recorded in prose that nothing enforces — and the instruction invited it: PROMPT-TEMPLATE.md §15 asked for observations "documented, not acted on", and "documented" was satisfied by the paragraph. The template was corrected in the same session; **this gate is the mechanism that correction points at.** It REFUSES rather than warns, for gate 10's reason. It PASSES quietly when there is no observations section, deliberately — a gate that taxes every push is one that gets disabled within a week. `--fast` (stdlib file reads only). WHY 10 IS HERE (2026-08-18, R-341). R-341 booked two dated measurements — +24 h and +7 d — as a sentence inside a register row. Nothing read those dates, and nothing would have said a word when they passed; the row would simply have gone quiet and stayed that way. That is the same shape as R-242 (a rule filed without a mechanism, which recurred the next day) and as the R-29 census finding below, where the checks nobody was told to run were the ones that had been failing for weeks. The dates now live in a machine-readable block INSIDE OPEN-ITEMS.md — inside, so there is no sidecar to drift from the register — and this gate refuses the push once one comes due. It REFUSES rather than warns, deliberately: a warning is the thing that gets scrolled past, and this repo has the census to prove it. It is `--fast` (stdlib file read, no network) so it runs in both the pre-push hook and CI. **It is not a scheduler and its docstring says so** — it fires on the next push after a date passes, not on the date. WHY 7 IS HERE (2026-08-08, R-242). R-242 was filed as a rule with no mechanism — *a controller release is not finished until a golden carries it* — and RECURRED THE NEXT DAY: v0.206.0 shipped while the vouched golden still carried 0.205.0, so a machine installed that morning would have got neither of the R-241 fixes. Two occurrences in two days, the first (R-239) invisible until a walk measured it from the customer's side. It is `--fast` because both the pre-push hook and CI run only `--fast`; a non-fast gate would run in neither, which is the R-29 failure this runner ended. That constraint is why it checks the BAKE and not the vouch — the full reasoning is in its docstring. WHY 6 IS HERE AND WAS NOT (2026-08-06, R-229 deferred leg). instructions_gate.py LIVES in this repo's scripts/ and was registered in the controller and agent runners on the day it was written — but not in this one, because this repo's own CLAUDE.md was still 27 lines over the ceiling and a registered-but-failing gate refuses every push through .githooks/pre-push. The file was trimmed (227 -> 115 effective lines, core + .claude/rules/) and the gate registered in the same session. A check that does not run in the place it applies is the exact failure the R-29 gate census found. WHY THIS FILE EXISTS (2026-08-02, closing R-29 leg (a) and half of 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. Neither failure was harmful in effect, which was checked line by line; nothing would have said so if they had been. The fix is not more gates, it is one place to run them from. `app-catalog-felhom.eu/scripts/catalog_gates.py` is the canonical shape (R-161) and this copies it deliberately rather than inventing a second one. `site_gates.py` is a GATE — eight assertions in one file — and is NOT the model for this file. A runner that invokes separate gates is the shape that survives; copying site_gates would just add a ninth monolith. FAIL-CLOSED. A gate script that is missing is a FAILURE, never a skip, and the exact path tried is printed. A runner that quietly drops 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, and the operator needs to know which they have. """ import os import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SCRIPTS = os.path.join(ROOT, "scripts") # (label, absolute script path, args, fast, exemptible) # # `exemptible` — R-404, 2026-09-01. TRUE means: on a DOCUMENTS-ONLY push this gate's CONVICTION is # reported as ADVISORY and does not refuse the push. It is TRUE for exactly ONE gate and the list is # meant to stay that length. # # WHY ONLY golden-currency. It is the only gate here that does not examine the push at all: it # compares the controller's newest CHANGELOG heading against this repo's bake evidence and returns # the same verdict whatever you are pushing. Every other gate convicts on something IN the change, # so a documents push that trips one has a broken document and must be refused. # # The gate's own verdict is UNCHANGED — it still runs on every push and still says the same thing. # What changed is who is refused. See scripts/push_scope.py and OPEN-ITEMS.md R-404. GATES = [ ("site", os.path.join(SCRIPTS, "site_gates.py"), [], True, False), ("hostinstall", os.path.join(SCRIPTS, "hostinstall_gates.py"), [], True, False), ("hub-confirm", os.path.join(SCRIPTS, "hub_confirm_gate.py"), [], True, False), ("manifest-bearer", os.path.join(SCRIPTS, "manifest_bearer_gate.py"), [], True, False), ("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True, False), ("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True, False), ("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True, True), ("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True, False), # R-324 — the hub composes every customer e-mail and renders the binding pages, and until # 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads. ("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False), # R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read. ("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False), # R-369 — two files held open work and only one called itself the source of truth, so a READY # finding sat in ROADMAP.md for 25 days invisible to every "grep the register" rule and was # rediscovered by an overnight drill. Fast: two file reads. ("one-register", os.path.join(SCRIPTS, "one_register_gate.py"), [], True, False), # R-405 — the 2026-08-22 compression sweep moved R-87 into CLOSED-ITEMS.md while its own state # cell read READY; R-378 caught six siblings in the same session and missed this one, so it sat # in the wrong file for nine days while the ranking paragraph pointed at nothing. Fast: two # file reads. ("closed-register", os.path.join(SCRIPTS, "closed_register_gate.py"), [], True, False), # R-389 — a live finding lived in a REPORT.md observations paragraph and nowhere else, and # REPORT.md is overwritten every session. Fast: stdlib file reads. ("observations", os.path.join(SCRIPTS, "observations_gate.py"), [ROOT], True, False), # R-421 — every registered gate across all four repos ships with a decoy test, or is # named in the exemption list with its row. Registered LAST, after every runner was green: # a failing gate refuses every push, which is what instructions_gate learned the hard way. ("decoy-coverage", os.path.join(SCRIPTS, "decoy_coverage_gate.py"), [ROOT, os.path.join(os.path.dirname(ROOT), "felhom-controller"), os.path.join(os.path.dirname(ROOT), "felhom-agent"), os.path.join(os.path.dirname(ROOT), "app-catalog-felhom.eu")], True, False), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} ADVISORY = "ADVISORY" # R-404: a conviction that is reported, loudly, and does not refuse 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).") 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, and a # runner that swallows them makes a conviction unreadable. It is ALSO collected, so the advisory # block below can quote the gate's own words instead of re-deriving the version itself. Printing # happens line by line as it arrives, so this is a tee and not a capture. proc = subprocess.Popen([sys.executable, path] + args, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) lines = [] for raw in iter(proc.stdout.readline, b""): line = raw.decode("utf-8", "replace").rstrip("\n") lines.append(line) print(line, flush=True) proc.stdout.close() return proc.wait(), "\n".join(lines) def main(argv): fast = "--fast" in argv scope = "code" rest = [] for a in argv: if a == "--fast": continue if a.startswith("--scope="): scope = a.split("=", 1)[1].strip() continue rest.append(a) if rest: print("unknown argument(s): %s" % " ".join(rest)) print("usage: python3 scripts/repo_gates.py [--fast] [--scope=code|docs]") return 2 if scope not in ("code", "docs"): # Fail closed: an unrecognised scope is never quietly treated as 'docs'. print("unrecognised --scope=%s — expected 'code' or 'docs'." % scope) print("An unrecognised scope is REFUSED rather than assumed, because the only assumption") print("that could be wrong in a dangerous direction is 'docs'.") 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("repo_gates (felhom.eu) — %d gate(s)%s%s" % (len(selected), " [--fast]" if fast else "", " [scope=docs]" if scope == "docs" else "")) if skipped: print(" --fast SKIPPED (deliberate periodic runs, never in a hook): %s" % ", ".join(skipped)) hooks_armed_note(ROOT) results = [] for label, path, args, _f, exemptible in selected: rc, text = run_gate(label, path, args) results.append((label, rc, exemptible, text)) print("\n" + "=" * 78) print("== summary") print("=" * 78) worst = 0 advisories = [] for label, rc, exemptible, text in results: # An ADVISORY is a CONVICTION (rc == 1) on a documents-only push, for a gate registered as # exemptible. INCONCLUSIVE (rc == 2) is deliberately NOT exemptible: it was never a # conviction, and treating "we could not tell" as "we forgive it" is a different decision # that nobody made. advisory = (scope == "docs" and rc == 1 and exemptible) if advisory: advisories.append((label, text)) print(" %-18s %-13s (exit %d)" % (label, ADVISORY, rc)) continue 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 advisories: _print_advisory_block(advisories) if worst == 0: print("\nall felhom.eu gates OK" + (" (with %d advisory — see above)" % len(advisories) if advisories else "")) return 0 convicted = [l for l, rc, _e, _t in results if rc == 1 and not (scope == "docs" and _e)] undecided = [l for l, rc, _e, _t 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 def _print_advisory_block(advisories): """Its own block, after the table, because a line inside a table is easy to miss. R-404's whole premise is that the warning must NOT go quiet — only its consequence changes. If this block ever stops printing, the change has become a silencing and Scenario A has failed. """ print("\n" + "!" * 78) for label, text in advisories: print("!! ADVISORY — %s convicted, and this push is NOT refused for it." % label) # Quote the gate's own numbers rather than re-deriving them; a second implementation of # "which version owes a golden" is a second thing that can be wrong. for line in text.splitlines(): t = line.strip() if t.startswith("newest released controller") or t.startswith("newest golden baked"): print("!! %s" % t) print("!!") print("!! This push touches DOCUMENTS ONLY, so it can neither create this debt nor clear") print("!! it — and the push that DOES clear it (a bake record under documentation/tests/)") print("!! is itself documents-only. Blocking here blocked the cure.") print("!!") print("!! WHAT CLEARS IT: bake a golden per documentation/runbooks/RUNBOOK-manual-build.md") print("!! section 4.1, then vouch it (a THREE-field change: golden_version + agent_version") print("!! + min_agent). The debt stays visible in STATUS.md and in the controller repo's") print("!! own golden-notice until then.") print("!" * 78) if __name__ == "__main__": sys.exit(main(sys.argv[1:]))