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>
228 lines
10 KiB
Python
228 lines
10 KiB
Python
"""Exercise the kvb-org-rasci register: fields, roles, priority order, claims, artifacts, tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
RASCI = REPO_ROOT / "skills" / "kvb-org-rasci" / "scripts" / "rasci.py"
|
|
COUNCIL = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "council.py"
|
|
HEADER = "| Prio | Task | OKR | Status | Worker | Artifacts | Start | End | R | A | S | C | I |"
|
|
|
|
|
|
def local(iso: str) -> str:
|
|
return datetime.datetime.fromisoformat(iso).astimezone().strftime("%m-%d %H:%M")
|
|
|
|
|
|
class TestRasci(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.home = Path(self.tmp.name) / "home"
|
|
self.root = Path(self.tmp.name) / "council"
|
|
self.sessions = self.home / ".claude" / "sessions"
|
|
self.sessions.mkdir(parents=True)
|
|
for sid, name in (("s1", "One"), ("s2", "Two")):
|
|
(self.sessions / f"{sid}.json").write_text(
|
|
json.dumps({"sessionId": sid, "name": name}), encoding="utf-8"
|
|
)
|
|
|
|
def _run(self, *args: str, script: Path = RASCI, sid: str = "s1") -> subprocess.CompletedProcess:
|
|
env = {
|
|
"PATH": "/usr/bin:/bin",
|
|
"HOME": str(self.home),
|
|
"AGENTS_COUNCIL_ROOT": str(self.root),
|
|
"CLAUDE_CODE_SESSION_ID": sid,
|
|
}
|
|
return subprocess.run(
|
|
[sys.executable, str(script), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=self.tmp.name,
|
|
check=False,
|
|
)
|
|
|
|
def _out(self, *args: str) -> list[str]:
|
|
result = self._run(*args)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
return result.stdout.splitlines()
|
|
|
|
def _claim(self, office: str, session: str, name: str, work: str) -> None:
|
|
path = self.root / office
|
|
path.mkdir(parents=True)
|
|
claim = {
|
|
"office": office,
|
|
"session": session,
|
|
"runtime": "claude-code",
|
|
"name": name,
|
|
"since": "2026-11-01T00:00:00+00:00",
|
|
"beat": time.time(),
|
|
"work": work,
|
|
}
|
|
(path / "claim.json").write_text(json.dumps(claim), encoding="utf-8")
|
|
|
|
def test_matrix_orders_by_priority_and_lists_roles(self):
|
|
start = "2026-11-01T10:00:00+00:00"
|
|
self._run("set", "work/a", "--title", "Alpha", "--objective", "Ship A", "--prio", "2",
|
|
"--status", "running", "--worker", "Skills", "--start", start)
|
|
self._run("role", "work/a", "Skills", "ar")
|
|
self._run("role", "work/a", "Operator", "ic")
|
|
self._run("role", "work/a", "Pkgmgr", "C")
|
|
self._run("set", "work/b", "--title", "Beta", "--prio", "1", "--worker", "subagent Explore (haiku)")
|
|
self._run("role", "work/b", "Pkgmgr", "R")
|
|
lines = self._out("show")
|
|
self.assertEqual(lines[0], HEADER)
|
|
self.assertEqual(
|
|
lines[2], "| 1 | work/b: Beta | - | queued | subagent Explore (haiku) | - | - | - | Pkgmgr | | | | |"
|
|
)
|
|
self.assertEqual(
|
|
lines[3],
|
|
f"| 2 | work/a: Alpha | Ship A | running | Skills | - | {local(start)} | - "
|
|
"| Skills | Skills | | Operator, Pkgmgr | Operator |",
|
|
)
|
|
self.assertEqual(
|
|
lines[4], "gaps: work/b has 0 A; work/b has no key result; work/a has no key result"
|
|
)
|
|
|
|
def test_key_results_gate_done_and_fill_the_okr_column(self):
|
|
self._run("set", "work/a", "--objective", "Ship A", "--status", "running")
|
|
self._run("role", "work/a", "Skills", "RA")
|
|
self._run("kr", "work/a", "add", "tests green")
|
|
self._run("kr", "work/a", "add", "released")
|
|
self._run("kr", "work/a", "met", "1")
|
|
lines = self._out("show")
|
|
self.assertIn("| Ship A (1/2 KR) | running |", lines[2])
|
|
self.assertEqual(len(lines), 3)
|
|
self.assertEqual(self._run("set", "work/a", "--status", "done").returncode, 2)
|
|
self.assertIn("key result 2 [ ]: released", self._out("get", "work/a"))
|
|
self._run("kr", "work/a", "met", "2")
|
|
self.assertEqual(self._run("set", "work/a", "--status", "done").returncode, 0)
|
|
self.assertEqual(self._out("show"), ["no tasks"])
|
|
|
|
def test_status_sets_start_and_end(self):
|
|
self._run("set", "work/a", "--status", "running")
|
|
self._run("set", "work/a", "--status", "done", "--description", "long text")
|
|
lines = self._out("get", "work/a")
|
|
self.assertIn("status: done", lines)
|
|
self.assertIn("description: long text", lines)
|
|
self.assertTrue(any(line.startswith("start: 20") for line in lines))
|
|
self.assertTrue(any(line.startswith("end: 20") for line in lines))
|
|
self._run("set", "work/a", "--status", "running")
|
|
self.assertFalse(any(line.startswith("end:") for line in self._out("get", "work/a")))
|
|
|
|
def test_done_tasks_show_only_with_all(self):
|
|
self._run("set", "work/a", "--status", "done", "--end", "2026-11-01T12:00:00+00:00")
|
|
self._run("role", "work/a", "Skills", "RA")
|
|
self.assertEqual(self._out("show"), ["no tasks"])
|
|
self.assertIn("| done | - |", self._out("show", "--all")[2])
|
|
|
|
def test_artifacts_and_tools(self):
|
|
self._run("set", "work/a", "--workdir", "/w/a", "--branch", "feat/a", "--status", "done",
|
|
"--artifact", "image:core:dev", "--artifact", "volume:db",
|
|
"--tool", "council.py", "--tool", "make test")
|
|
self._run("set", "work/b", "--worker", "Two", "--tool", "make test", "--artifact", "dir:/tmp/b")
|
|
self._run("set", "work/a", "--drop-artifact", "volume:db")
|
|
self._run("set", "work/b", "--branch", "")
|
|
rows = [line.split("\t") for line in self._out("artifacts")]
|
|
self.assertEqual(
|
|
[row[:4] for row in rows],
|
|
[
|
|
["done", "work/a", "-", "workdir:/w/a"],
|
|
["done", "work/a", "-", "branch:feat/a"],
|
|
["done", "work/a", "-", "image:core:dev"],
|
|
["queued", "work/b", "Two", "dir:/tmp/b"],
|
|
],
|
|
)
|
|
self.assertEqual(rows[0][4], "-")
|
|
self.assertRegex(rows[2][4], r"^\d\d-\d\d \d\d:\d\d$")
|
|
self.assertEqual([r.split("\t")[1] for r in self._out("artifacts", "--ended")], ["work/a"] * 3)
|
|
self.assertEqual(len(self._out("artifacts", "--task", "work/b")), 1)
|
|
self.assertIn("| queued | Two | 1 |", self._out("show")[2])
|
|
self.assertTrue(any(line.startswith("artifact: image:core:dev (since ") for line in self._out("get", "work/a")))
|
|
self.assertEqual(
|
|
self._out("tools"), ["2\tmake test\twork/a work/b", "1\tcouncil.py\twork/a"]
|
|
)
|
|
self.assertIn("tool: council.py", self._out("get", "work/a"))
|
|
|
|
def test_claims_join_the_matrix_with_holder_as_ra(self):
|
|
self._claim("work/live", "s1", "One", "Gamma")
|
|
self._claim("cluster/dead", "s9", "Gone", "Delta")
|
|
self._claim("chair", "s1", "One", "allocator")
|
|
self._run("set", "work/live", "--prio", "1", "--status", "running")
|
|
self._run("role", "work/live", "Two", "S")
|
|
lines = self._out("show")
|
|
self.assertTrue(lines[2].startswith("| 1 | work/live | - | running | One |"))
|
|
self.assertTrue(lines[2].endswith("| One | One | Two | | |"))
|
|
self.assertTrue(any(line.startswith("| - | cluster/dead | - | stale | Gone |") for line in lines))
|
|
self.assertFalse(any("chair" in line for line in lines))
|
|
|
|
def test_council_claim_takeover_and_release_write_through(self):
|
|
self.assertEqual(self._run("claim", "work/x", "--work", "Do X", script=COUNCIL).returncode, 0)
|
|
lines = self._out("get", "work/x")
|
|
self.assertIn("status: running", lines)
|
|
self.assertIn("worker: One", lines)
|
|
self.assertIn("description: Do X", lines)
|
|
self.assertIn(f"workdir: {self.tmp.name}", lines)
|
|
self.assertIn("roles: One=RA", lines)
|
|
(self.sessions / "s1.json").unlink()
|
|
self.assertEqual(self._run("takeover", "work/x", "--work", "Do X", script=COUNCIL, sid="s2").returncode, 0)
|
|
lines = self._out("get", "work/x")
|
|
self.assertIn("worker: Two", lines)
|
|
self.assertIn("roles: Two=RA", lines)
|
|
self._run("kr", "work/x", "add", "X shipped")
|
|
self.assertEqual(self._run("release", "work/x", script=COUNCIL, sid="s2").returncode, 0)
|
|
lines = self._out("get", "work/x")
|
|
self.assertIn("status: stopped", lines)
|
|
self.assertTrue(any(line.startswith("end: 20") for line in lines))
|
|
|
|
def test_rm_and_clear_a_party(self):
|
|
self._run("set", "work/a", "--title", "Alpha")
|
|
self._run("role", "work/a", "Skills", "RA")
|
|
self._run("role", "work/a", "Pkgmgr", "C")
|
|
self._run("role", "work/a", "Pkgmgr", "-")
|
|
self.assertTrue(self._out("show")[2].endswith("| Skills | Skills | | | |"))
|
|
self.assertEqual(self._out("rm", "work/a"), ["removed work/a"])
|
|
self.assertEqual(self._out("show"), ["no tasks"])
|
|
|
|
def test_invalid_input_exits_2(self):
|
|
for args in (
|
|
("role", "work/a", "Skills", "RR"),
|
|
("role", "work/a", "Skills", "Q"),
|
|
("set", "Bad Task"),
|
|
("set", "work/a", "--prio", "0"),
|
|
("set", "work/a", "--status", "finished"),
|
|
("set", "work/a", "--start", "yesterday"),
|
|
("get", "work/none"),
|
|
("kr", "work/a", "met", "1"),
|
|
("set", "work/a", "--artifact", "no-kind"),
|
|
):
|
|
with self.subTest(args=args):
|
|
self.assertEqual(self._run(*args).returncode, 2)
|
|
self.assertFalse((self.root / "rasci.json").exists())
|
|
|
|
def test_pipe_in_a_title_does_not_break_the_table(self):
|
|
self._run("set", "work/a", "--title", "a | b")
|
|
self._run("role", "work/a", "Skills", "RA")
|
|
self.assertTrue(self._out("show")[2].startswith("| - | work/a: a \\| b | - | queued |"))
|
|
|
|
|
|
class TestOrgSkillsUseTheRegister(unittest.TestCase):
|
|
def test_every_org_skill_names_the_register(self):
|
|
for skill in sorted((REPO_ROOT / "skills").glob("kvb-org-*/SKILL.md")):
|
|
if skill.parent.name == "kvb-org-rasci":
|
|
continue
|
|
with self.subTest(skill=skill.parent.name):
|
|
self.assertIn("`kvb-org-rasci`", skill.read_text(encoding="utf-8"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|