fix(kvb-check-gate): exclude operator-listed targets from every gate

gate_excluded_targets in .claude/agents-council/config.json of the passwd home (never $HOME or the environment, so no caller swaps in its own list) names targets no gate requires or runs. status shows them excluded and keeps the gate green without them, naming the exclusion and the config in the green line; excluding every target keeps the gate open and skips the hotfix check. run refuses an excluded target with exit 2. Operator order 2026-11-11: no oracle tests in any gate.

🎯 Fix: 93 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-11 22:34:47 +00:00
1 parent e805b07c27
commit bcbcd9f9a2
3 files changed
+98 -13

No files matched your search

+5 -3
View File
@@ -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 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 `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. 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 `${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 `kvb-gate.json` in the git common dir: linked worktrees share them, a new
commit starts from zero. commit starts from zero.
@@ -30,8 +32,8 @@ commit starts from zero.
3. **Status**: `gate.py status --repo <root>`. `gate: green` ends here; a 3. **Status**: `gate.py status --repo <root>`. `gate: green` ends here; a
`missing hotfix` reason means back to step 1. `missing hotfix` reason means back to step 1.
4. **Admit**: every gate target is heavy and runs only inside 4. **Admit**: every gate target is heavy and runs only inside
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold` (step 5), `${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 Only a single test module
self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py
admit --kind test-nostack`), and it never turns a gate target green. A admit --kind test-nostack`), and it never turns a gate target green. A
hotfix gate adds `--hotfix <slug>` and takes the next free gate slot. hotfix gate adds `--hotfix <slug>` and takes the next free gate slot.
+46 -10
View File
@@ -6,10 +6,14 @@ Args:
parsed statically from the Makefile make reads in <dir> and every file parsed statically from the Makefile make reads in <dir> and every file
it includes; make is never run. it includes; make is never run.
status --repo <dir> [--json]: per listed target green, red or missing for status --repo <dir> [--json]: per listed target green, red or missing for
HEAD, from `kvb-gate.json` in the git common dir; `hotfix.py pending HEAD, from `kvb-gate.json` in the git common dir, or excluded when the
--target <t> ...` of the kvb-git-hotfix skill, given the listed list gate_excluded_targets in CONFIG names it; an excluded target never
targets, keeps the gate open while a merged hotfix that turns one of keeps the gate open, excluding every target does. CONFIG is
them green is missing from HEAD. .claude/agents-council/config.json in the passwd home of the account
running gate.py, never $HOME. `hotfix.py pending --target <t> ...`
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 <dir> --target <t> [--target ...] --log-dir <dir> run --repo <dir> --target <t> [--target ...] --log-dir <dir>
[--fail-regex <re> ...]: under the worktree lock, run each target as [--fail-regex <re> ...]: under the worktree lock, run each target as
`make -C <dir> <t>` with stdout and stderr in `make -C <dir> <t>` with stdout and stderr in
@@ -44,7 +48,8 @@ Args:
Output: Output:
list: one `<target>\t<file>:<line>` per target. list: one `<target>\t<file>:<line>` per target.
status: one `<status>\t<target>\t<log>\t<finished>\t<reason>` per target, status: one `<status>\t<target>\t<log>\t<finished>\t<reason>` per target,
then `gate: green`, or `gate: open (...)` naming what is missing, then `gate: green`, `gate: green (excluded: <target>, ... by
<CONFIG>)`, or `gate: open (...)` naming what is missing,
`missing hotfix: <slug>, ...` included. `missing hotfix: <slug>, ...` included.
run: one `<status>\t<target>\t<log>\t<reason>` per target; on a void run: one `<status>\t<target>\t<log>\t<reason>` per target; on a void
`void: merge main for hotfix <slug>, then rerun`. `void: merge main for hotfix <slug>, then rerun`.
@@ -54,8 +59,8 @@ Output:
Exit codes: Exit codes:
list: 0 done, 2 usage error. list: 0 done, 2 usage error.
status: 0 gate green, 1 gate open, 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 run: 0 every target green, 1 a target red, 2 usage error, an excluded
files or HEAD moved during a run, 3 void: the remaining targets are 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 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. 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; hold: the command's exit code unchanged, 128+N when signal N ended it;
@@ -71,6 +76,7 @@ import fcntl
import glob import glob
import json import json
import os import os
import pwd
import re import re
import shlex import shlex
import signal import signal
@@ -103,6 +109,8 @@ POLL_SECONDS = 0.1
BUSY = os.EX_TEMPFAIL BUSY = os.EX_TEMPFAIL
FORWARDED = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) FORWARDED = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
HOTFIX = Path(__file__).resolve().parents[2] / "kvb-git-hotfix" / "scripts" / "hotfix.py" 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: def fail(message: str) -> NoReturn:
@@ -380,6 +388,18 @@ def repository(value: str) -> Path:
return repo 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: def cmd_list(args: argparse.Namespace) -> int:
found = targets(repository(args.repo)) found = targets(repository(args.repo))
if args.json: if args.json:
@@ -400,22 +420,30 @@ def cmd_status(args: argparse.Namespace) -> int:
sha = head(repo) sha = head(repo)
state = load(state_path(repo)) state = load(state_path(repo))
dirty, untracked = tree(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 = [] rows = []
for target in found: for target in found:
entry = state[sha].get(target) if sha in state else None 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"] missing = [r["target"] for r in rows if r["status"] == "missing"]
red = [r["target"] for r in rows if r["status"] == "red"] red = [r["target"] for r in rows if r["status"] == "red"]
reasons = [] reasons = []
if not found: if not found:
reasons.append("no make test* target found") reasons.append("no make test* target found")
elif not required:
reasons.append("every make test* target is excluded")
if missing: if missing:
reasons.append(f"missing: {', '.join(missing)}") reasons.append(f"missing: {', '.join(missing)}")
if red: if red:
reasons.append(f"red: {', '.join(red)}") reasons.append(f"red: {', '.join(red)}")
if dirty: if dirty:
reasons.append(f"dirty: {len(dirty)} tracked file(s)") 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: if hotfixes:
reasons.append(f"missing hotfix: {', '.join(hotfixes)}") reasons.append(f"missing hotfix: {', '.join(hotfixes)}")
gate = "open" if reasons else "green" 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"))])) print("\t".join([r["status"], r["target"], *(cell(r.get(k)) for k in ("log", "finished", "reason"))]))
if untracked: if untracked:
print(f"untracked\t{len(untracked)} file(s), not part of the gate") 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 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] unknown = [t for t in args.target if t not in found]
if unknown: if unknown:
fail(f"not a make test* target of {repo}: {', '.join(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: with worktree_lock(repo, ["gate.py", "run", "--repo", str(repo), *target_flags(args.target)], []) as lock:
sha = head(repo) sha = head(repo)
dirty, untracked = tree(repo) dirty, untracked = tree(repo)
+47
View File
@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
import argparse
import contextlib import contextlib
import fcntl import fcntl
import importlib.util import importlib.util
import io import io
import json import json
import os import os
import pwd
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -17,6 +19,7 @@ import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import ClassVar from typing import ClassVar
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py" 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"} 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"} 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") FAILED = ("--fail-regex", r"FAILED TARGETS: \S")
SYNC_HOLDER = "4242\thotfix.py sync --task t\n" SYNC_HOLDER = "4242\thotfix.py sync --task t\n"
COUNTING_CHILD = ( COUNTING_CHILD = (
@@ -142,6 +146,49 @@ class GateCase(unittest.TestCase):
return self.gate("status", "--repo", str(self.repo)).stdout.splitlines()[-1] 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): class TestGreenPath(GateCase):
files = GREEN files = GREEN