diff --git a/skills/kvb-check-gate/SKILL.md b/skills/kvb-check-gate/SKILL.md index e0bb6ae..91843f9 100644 --- a/skills/kvb-check-gate/SKILL.md +++ b/skills/kvb-check-gate/SKILL.md @@ -11,9 +11,11 @@ confidence ≥ 90 %, break risk ≤ 5 %, cost and time ≤ 100 % in the cause) before every `make test*` target of the project, each Makefile target named `test` or starting with `test-`, ran green on the exact head with a clean tracked tree. Until then the numbers are provisional and every target not green is a residual. +Targets in the operator's `gate_excluded_targets` are excluded, not +residuals. `${CLAUDE_SKILL_DIR}/scripts/gate.py` reads the targets from the Makefile and -its includes without running make, and keeps results per commit in +its includes and keeps results per commit in `kvb-gate.json` in the git common dir: linked worktrees share them, a new commit starts from zero. @@ -30,8 +32,8 @@ commit starts from zero. 3. **Status**: `gate.py status --repo `. `gate: green` ends here; a `missing hotfix` reason means back to step 1. 4. **Admit**: every gate target is heavy and runs only inside - `${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold` (step 5), - which grants by rule and releases on exit. Only a single test module + `${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold` (step 5). + Only a single test module self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py admit --kind test-nostack`), and it never turns a gate target green. A hotfix gate adds `--hotfix ` and takes the next free gate slot. diff --git a/skills/kvb-check-gate/scripts/gate.py b/skills/kvb-check-gate/scripts/gate.py index 683a6c0..8d364ef 100755 --- a/skills/kvb-check-gate/scripts/gate.py +++ b/skills/kvb-check-gate/scripts/gate.py @@ -6,10 +6,14 @@ Args: parsed statically from the Makefile make reads in and every file it includes; make is never run. status --repo [--json]: per listed target green, red or missing for - HEAD, from `kvb-gate.json` in the git common dir; `hotfix.py pending - --target ...` of the kvb-git-hotfix skill, given the listed - targets, keeps the gate open while a merged hotfix that turns one of - them green is missing from HEAD. + HEAD, from `kvb-gate.json` in the git common dir, or excluded when the + list gate_excluded_targets in CONFIG names it; an excluded target never + keeps the gate open, excluding every target does. CONFIG is + .claude/agents-council/config.json in the passwd home of the account + running gate.py, never $HOME. `hotfix.py pending --target ...` + of the kvb-git-hotfix skill, given the other listed targets, keeps the + gate open while a merged hotfix that turns one of them green is + missing from HEAD. run --repo --target [--target ...] --log-dir [--fail-regex ...]: under the worktree lock, run each target as `make -C ` with stdout and stderr in @@ -44,7 +48,8 @@ Args: Output: list: one `\t:` per target. status: one `\t\t\t\t` per target, - then `gate: green`, or `gate: open (...)` naming what is missing, + then `gate: green`, `gate: green (excluded: , ... by + )`, or `gate: open (...)` naming what is missing, `missing hotfix: , ...` included. run: one `\t\t\t` per target; on a void `void: merge main for hotfix , then rerun`. @@ -54,8 +59,8 @@ Output: Exit codes: list: 0 done, 2 usage error. status: 0 gate green, 1 gate open, 2 usage error. - run: 0 every target green, 1 a target red, 2 usage error, dirty tracked - files or HEAD moved during a run, 3 void: the remaining targets are + run: 0 every target green, 1 a target red, 2 usage error, an excluded + target, dirty tracked files or HEAD moved during a run, 3 void: the remaining targets are neither run nor recorded, 75 (EX_TEMPFAIL) busy: another run or hold holds the worktree, or a sync still holds it after SYNC_WAIT_SECONDS. hold: the command's exit code unchanged, 128+N when signal N ended it; @@ -71,6 +76,7 @@ import fcntl import glob import json import os +import pwd import re import shlex import signal @@ -103,6 +109,8 @@ POLL_SECONDS = 0.1 BUSY = os.EX_TEMPFAIL FORWARDED = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) HOTFIX = Path(__file__).resolve().parents[2] / "kvb-git-hotfix" / "scripts" / "hotfix.py" +CONFIG = Path(pwd.getpwuid(os.getuid()).pw_dir) / ".claude" / "agents-council" / "config.json" +EXCLUDED = "gate_excluded_targets" def fail(message: str) -> NoReturn: @@ -380,6 +388,18 @@ def repository(value: str) -> Path: return repo +def excluded() -> set[str]: + if not CONFIG.is_file(): + return set() + try: + listed = json.loads(CONFIG.read_text(encoding="utf-8")).get(EXCLUDED, []) + except (OSError, ValueError, AttributeError) as error: + fail(f"cannot read {EXCLUDED} from {CONFIG}: {error}") + if not isinstance(listed, list) or not all(isinstance(target, str) for target in listed): + fail(f"{EXCLUDED} in {CONFIG} must be a list of target names") + return set(listed) + + def cmd_list(args: argparse.Namespace) -> int: found = targets(repository(args.repo)) if args.json: @@ -400,22 +420,30 @@ def cmd_status(args: argparse.Namespace) -> int: sha = head(repo) state = load(state_path(repo)) dirty, untracked = tree(repo) + skip = excluded() + required = [target for target in found if target not in skip] + skipped = [target for target in found if target in skip] rows = [] for target in found: entry = state[sha].get(target) if sha in state else None - rows.append({"target": target, "status": "missing"} if entry is None else {"target": target, **entry}) + if target in skip: + rows.append({"target": target, "status": "excluded", "reason": f"{EXCLUDED} in {CONFIG}"}) + else: + rows.append({"target": target, "status": "missing"} if entry is None else {"target": target, **entry}) missing = [r["target"] for r in rows if r["status"] == "missing"] red = [r["target"] for r in rows if r["status"] == "red"] reasons = [] if not found: reasons.append("no make test* target found") + elif not required: + reasons.append("every make test* target is excluded") if missing: reasons.append(f"missing: {', '.join(missing)}") if red: reasons.append(f"red: {', '.join(red)}") if dirty: reasons.append(f"dirty: {len(dirty)} tracked file(s)") - hotfixes = missing_hotfixes(repo, list(found)) if hotfix_checks() else [] + hotfixes = missing_hotfixes(repo, required) if required and hotfix_checks() else [] if hotfixes: reasons.append(f"missing hotfix: {', '.join(hotfixes)}") gate = "open" if reasons else "green" @@ -431,7 +459,12 @@ def cmd_status(args: argparse.Namespace) -> int: print("\t".join([r["status"], r["target"], *(cell(r.get(k)) for k in ("log", "finished", "reason"))])) if untracked: print(f"untracked\t{len(untracked)} file(s), not part of the gate") - print("gate: green" if gate == "green" else f"gate: open ({'; '.join(reasons)})") + if reasons: + print(f"gate: open ({'; '.join(reasons)})") + elif skipped: + print(f"gate: green (excluded: {', '.join(skipped)} by {CONFIG})") + else: + print("gate: green") return 0 if gate == "green" else 1 @@ -447,6 +480,9 @@ def cmd_run(args: argparse.Namespace) -> int: unknown = [t for t in args.target if t not in found] if unknown: fail(f"not a make test* target of {repo}: {', '.join(unknown)}") + barred = sorted(set(args.target) & excluded()) + if barred: + fail(f"excluded from every gate by {EXCLUDED} in {CONFIG}: {', '.join(barred)}") with worktree_lock(repo, ["gate.py", "run", "--repo", str(repo), *target_flags(args.target)], []) as lock: sha = head(repo) dirty, untracked = tree(repo) diff --git a/tests/test_gate.py b/tests/test_gate.py index 2101748..417d53d 100644 --- a/tests/test_gate.py +++ b/tests/test_gate.py @@ -2,12 +2,14 @@ from __future__ import annotations +import argparse import contextlib import fcntl import importlib.util import io import json import os +import pwd import shutil import signal import subprocess @@ -17,6 +19,7 @@ import time import unittest from pathlib import Path from typing import ClassVar +from unittest import mock REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py" @@ -68,6 +71,7 @@ RED = { } USAGE = {"Makefile": "test:\n\t@true\nbuild:\n\t@true\n"} VOID = {"Makefile": ".PHONY: test test-a test-b\ntest:\n\t@echo all\ntest-a:\n\t@echo a\ntest-b:\n\t@echo b\n"} +ORACLE = {"Makefile": ".PHONY: test test-oracle\ntest:\n\t@echo all\ntest-oracle:\n\t@echo oracle\n"} FAILED = ("--fail-regex", r"FAILED TARGETS: \S") SYNC_HOLDER = "4242\thotfix.py sync --task t\n" COUNTING_CHILD = ( @@ -142,6 +146,49 @@ class GateCase(unittest.TestCase): return self.gate("status", "--repo", str(self.repo)).stdout.splitlines()[-1] +class TestExcluded(GateCase): + files = ORACLE + + def call(self, gate: object, command: str, **fields: object) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with mock.patch.dict(os.environ, self.env, clear=True), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + code = getattr(gate, command)(argparse.Namespace(repo=str(self.repo), **fields)) + except SystemExit as stop: + code = stop.code + return code, out.getvalue(), err.getvalue() + + def exclude(self, gate: object, listed: object) -> None: + gate.CONFIG.write_text(json.dumps({"gate_excluded_targets": listed}), encoding="utf-8") + + def test_an_excluded_target_is_shown_excluded_never_required_and_refused_by_run(self): + gate = load_gate() + gate.CONFIG = self.root / "config.json" + run = {"log_dir": str(self.logs), "fail_regex": None} + self.assertEqual(self.call(gate, "cmd_run", target=["test"], **run)[0], 0) + code, out, _ = self.call(gate, "cmd_status", json=True) + self.assertEqual((code, json.loads(out)["open"]), (1, ["missing: test-oracle"])) + self.exclude(gate, ["test-oracle"]) + code, out, _ = self.call(gate, "cmd_status", json=True) + after = json.loads(out) + self.assertEqual((code, after["gate"]), (0, "green")) + self.assertEqual({r["target"]: r["status"] for r in after["targets"]}, {"test": "green", "test-oracle": "excluded"}) + self.assertEqual(self.call(gate, "cmd_status", json=False)[1].splitlines()[-1], f"gate: green (excluded: test-oracle by {gate.CONFIG})") + code, _, err = self.call(gate, "cmd_run", target=["test-oracle"], **run) + self.assertEqual(code, 2) + self.assertIn("excluded from every gate by gate_excluded_targets", err) + self.exclude(gate, ["test", "test-oracle"]) + code, out, _ = self.call(gate, "cmd_status", json=True) + self.assertEqual((code, json.loads(out)["open"]), (1, ["every make test* target is excluded"])) + self.exclude(gate, "test-oracle") + self.assertEqual(self.call(gate, "cmd_status", json=False)[0], 2) + + def test_the_exclusion_list_ignores_home_and_environment_overrides(self): + with mock.patch.dict(os.environ, {"HOME": str(self.root), "AGENTS_COUNCIL_CONFIG": str(self.root / "config.json")}): + gate = load_gate() + self.assertEqual(gate.CONFIG, Path(pwd.getpwuid(os.getuid()).pw_dir) / ".claude" / "agents-council" / "config.json") + + class TestGreenPath(GateCase): files = GREEN