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:
1 parent
e805b07c27
commit
bcbcd9f9a2
3 files changed
+98
-13
No files matched your search
@@ -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 <root>`. `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 <slug>` and takes the next free gate slot.
|
||||
|
||||
@@ -6,10 +6,14 @@ Args:
|
||||
parsed statically from the Makefile make reads in <dir> and every file
|
||||
it includes; make is never run.
|
||||
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
|
||||
--target <t> ...` 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 <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>
|
||||
[--fail-regex <re> ...]: under the worktree lock, run each target as
|
||||
`make -C <dir> <t>` with stdout and stderr in
|
||||
@@ -44,7 +48,8 @@ Args:
|
||||
Output:
|
||||
list: one `<target>\t<file>:<line>` 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.
|
||||
run: one `<status>\t<target>\t<log>\t<reason>` per target; on a void
|
||||
`void: merge main for hotfix <slug>, 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user