Files
skills/skills/kvb-org-organize/scripts/council.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 35dc37641d feat(org): add the kvb-org-rasci RASCI/OKR register and kvb-org-artefacts
rasci.py keeps the organisation's tasks in <council root>/rasci.json: RASCI roles, objective with key results, priority, worker, start, end, workdir, branch, tools and artifacts; done is refused while a key result is open. council.py writes every claim, takeover and release through, and show lists the live claims as one row per task with R, A, S, C and I columns. kvb-org-artefacts manages the artifacts in the same table for kvb-cleanup. The org, plan, triage, tune, cleanup, branch-gc and dream skills now record into and read from the register; five descriptions were tightened to keep the listing budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 00:47:23 +01:00

264 lines
8.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Claim, release and list kvb-org-organize offices in the shared council directory.
Environment:
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
/tmp/agents-council unless set, because all agents must agree on it.
CLAUDE_CODE_SESSION_ID: the caller's session; other runtimes pass --session.
Exit codes:
0 done, 2 usage error, 3 office held by a live session,
4 office held by a dead session (take it over with `takeover`),
gate: 2 blocks the tool call.
"""
from __future__ import annotations
import argparse
import datetime
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
HEARTBEAT_LIMIT = 3600
OFFICE = re.compile(r"^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$")
CLAIM = "claim.json"
RASCI = Path(__file__).resolve().parents[2] / "kvb-org-rasci" / "scripts" / "rasci.py"
def live_sessions() -> dict[str, str | None]:
found: dict[str, str | None] = {}
for path in (Path.home() / ".claude" / "sessions").glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if data.get("sessionId"):
found[data["sessionId"]] = data.get("name")
return found
def caller(session: str | None) -> tuple[str, str]:
if session:
return session, "other"
env = os.environ.get("CLAUDE_CODE_SESSION_ID")
if env:
return env, "claude-code"
print("council: no session id; pass --session", file=sys.stderr)
sys.exit(2)
def office_dir(office: str) -> Path:
if not OFFICE.match(office):
print(f"council: invalid office {office!r}", file=sys.stderr)
sys.exit(2)
return ROOT / office
def read_claim(path: Path) -> dict | None:
try:
return json.loads((path / CLAIM).read_text(encoding="utf-8"))
except (FileNotFoundError, ValueError):
return None
def is_stale(claim: dict | None, live: dict[str, str | None]) -> bool:
if claim is None:
return False
if claim["runtime"] == "claude-code":
return claim["session"] not in live
return time.time() - claim["beat"] > HEARTBEAT_LIMIT
def claims() -> list[tuple[str, dict]]:
found = []
for path in sorted(ROOT.glob(f"**/{CLAIM}")):
claim = read_claim(path.parent)
if claim is not None:
found.append((path.parent.relative_to(ROOT).as_posix(), claim))
return found
def write_claim(path: Path, office: str, sid: str, runtime: str, work: str) -> dict:
claim = {
"office": office,
"session": sid,
"runtime": runtime,
"name": live_sessions().get(sid),
"since": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
"beat": time.time(),
"work": work,
}
(path / CLAIM).write_text(json.dumps(claim), encoding="utf-8")
return claim
def here() -> dict[str, str | None]:
try:
head = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, check=False
)
except OSError:
head = None
branch = head.stdout.strip() if head and head.returncode == 0 else ""
return {"workdir": os.getcwd(), "branch": branch if branch not in ("", "HEAD") else None}
def register(office: str, holder: str, status: str, **extra: str | None) -> None:
if not office.startswith(("work/", "cluster/")):
return
try:
spec = importlib.util.spec_from_file_location("rasci", RASCI)
rasci = importlib.util.module_from_spec(spec)
spec.loader.exec_module(rasci)
rasci.track(office, holder, status, **extra)
except (OSError, SystemExit) as exc:
print(f"council: {office} is missing from the rasci register ({exc})", file=sys.stderr)
def cmd_claim(args: argparse.Namespace) -> int:
sid, runtime = caller(args.session)
path = office_dir(args.office)
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.mkdir()
except FileExistsError:
claim = read_claim(path)
if claim is not None and claim["session"] == sid:
claim = write_claim(path, args.office, sid, runtime, args.work)
register(args.office, claim["name"] or sid, "running", description=args.work, **here())
print(f"held {args.office}")
return 0
print(json.dumps(claim) if claim else f"{args.office}: claim in progress")
return 4 if is_stale(claim, live_sessions()) else 3
claim = write_claim(path, args.office, sid, runtime, args.work)
register(
args.office,
claim["name"] or sid,
"running",
description=args.work,
previous=getattr(args, "previous", None),
**here(),
)
print(f"elected {args.office}")
return 0
def cmd_takeover(args: argparse.Namespace) -> int:
path = office_dir(args.office)
claim = read_claim(path)
if path.exists() and not is_stale(claim, live_sessions()):
print(json.dumps(claim) if claim else f"{args.office}: claim in progress")
return 3
if claim is not None:
args.previous = claim["name"] or claim["session"]
# Two successors racing between this check and the rename can both win;
# kvb-org-organize names exactly one successor per office to keep that window closed.
if path.exists():
tomb = path.with_name(f"{path.name}.stale-{os.getpid()}-{time.time_ns()}")
try:
path.rename(tomb)
except FileNotFoundError:
pass
else:
shutil.rmtree(tomb)
return cmd_claim(args)
def cmd_release(args: argparse.Namespace) -> int:
sid, _ = caller(args.session)
path = office_dir(args.office)
claim = read_claim(path)
if claim is None:
print(f"{args.office}: not claimed")
return 0
if claim["session"] != sid and not is_stale(claim, live_sessions()):
print(json.dumps(claim))
return 3
shutil.rmtree(path)
register(args.office, claim["name"] or claim["session"], "done")
print(f"released {args.office}")
return 0
def cmd_beat(args: argparse.Namespace) -> int:
sid, _ = caller(args.session)
for office, claim in claims():
if claim["session"] == sid:
claim["beat"] = time.time()
(ROOT / office / CLAIM).write_text(json.dumps(claim), encoding="utf-8")
return 0
def cmd_list(args: argparse.Namespace) -> int:
live = live_sessions()
sid = None
if args.mine:
sid, _ = caller(args.session)
for office, claim in claims():
if sid is not None and claim["session"] != sid:
continue
state = "stale" if is_stale(claim, live) else "live"
holder = claim["name"] or claim["session"]
print(f"{office}\t{holder}\t{claim['since']}\t{state}\t{claim['work']}")
return 0
def in_git_worktree(target: Path) -> bool:
return any((path / ".git").exists() for path in (target, *target.parents))
def cmd_gate(_: argparse.Namespace) -> int:
payload = json.load(sys.stdin)
tool_input = payload.get("tool_input") or {}
target = tool_input.get("file_path") or tool_input.get("notebook_path")
if not target or not in_git_worktree(Path(target).resolve()):
return 0
if any(claim["session"] == payload.get("session_id") for _, claim in claims()):
return 0
script = Path(__file__).resolve()
print(
"kvb-org-organize: this session holds no claim, so it may not edit files in a git "
f"worktree. Claim the work first: python3 {script} claim work/<slug> "
"--work '<one line>'. Exit 3 or 4 means another agent holds it; read the "
"kvb-org-organize skill.",
file=sys.stderr,
)
return 2
def main() -> int:
parser = argparse.ArgumentParser(prog="council")
sub = parser.add_subparsers(dest="cmd", required=True)
for name, func in (("claim", cmd_claim), ("takeover", cmd_takeover)):
cmd = sub.add_parser(name)
cmd.add_argument("office")
cmd.add_argument("--work", required=True)
cmd.add_argument("--session")
cmd.set_defaults(func=func)
release = sub.add_parser("release")
release.add_argument("office")
release.add_argument("--session")
release.set_defaults(func=cmd_release)
beat = sub.add_parser("beat")
beat.add_argument("--session")
beat.set_defaults(func=cmd_beat)
listing = sub.add_parser("list")
listing.add_argument("--mine", action="store_true")
listing.add_argument("--session")
listing.set_defaults(func=cmd_list)
sub.add_parser("gate").set_defaults(func=cmd_gate)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())