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>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-01 00:47:23 +01:00
1 parent 624baccfc4
commit 35dc37641d
23 files changed
+866 -44

No files matched your search

+10
View File
@@ -57,6 +57,8 @@ flowchart LR
org --> org_coordinate[kvb-org-coordinate]
org --> org_perform[kvb-org-perform]
org --> org_matrix[kvb-org-matrix]
org --> org_rasci[kvb-org-rasci]
org --> org_artefacts[kvb-org-artefacts]
org --> org_robot[kvb-org-robot]
skills --> skills_dream[kvb-skills-dream]
skills --> skills_tune[kvb-skills-tune]
@@ -124,6 +126,8 @@ flowchart LR
coordinate[kvb-org-coordinate]
subagent_model[kvb-org-perform]
status_matrix[kvb-org-matrix]
rasci[kvb-org-rasci]
artefacts[kvb-org-artefacts]
robot[kvb-org-robot]
end
subgraph cat_skills [skills]
@@ -167,6 +171,12 @@ flowchart LR
funnel --> subagent_model
dialectic --> subagent_model
democracy --> merge_fix
democracy -. claim and release .-> rasci
funnel --> rasci
status_matrix --> rasci
cleanup --> artefacts
artefacts --> rasci
dream --> rasci
observe --> triage
observe --> ci_artifacts
+4 -4
View File
@@ -1,10 +1,10 @@
---
name: kvb-check-principles
description: >
Analyse a repository, path or diff with a hostile eye for violated
principles: SPOT, DRY, KISS, SRP, security at trust boundaries. Trigger on
/kvb-check-principles, "sei skeptisch", "kritische analyse", "review the architecture",
or "what is wrong with the code".
Analyse a repository, path or diff for violated principles: SPOT, DRY, KISS,
SRP, security at trust boundaries. Trigger on /kvb-check-principles, "sei
skeptisch", "kritische analyse", "review the architecture", or "what is
wrong with the code".
---
The stance is adversarial: the code is guilty until its structure proves
+3 -1
View File
@@ -40,7 +40,9 @@ jobs' logs, and a first clustering. The judgement in those steps stays yours.
3. **Coordinate every cluster.** Run the `kvb-org-organize` skill's Lineage and Peer
protocol for each cluster before fixing it: claim it, ask the lineage
whether someone already fixes it, and take their fix or keep yours. Keep
investigating while you wait for answers.
investigating while you wait for answers. In the `kvb-org-rasci` register
the cluster's objective is the root cause fixed, its key results are the
failing jobs passing again, one per job.
4. **Download artifacts per cluster.** Use the `kvb-ci-artifacts` skill for the
mechanics — it covers per-job logs while a run is still in progress, which
bundle to pull, and the grep hygiene that keeps a false positive from
+13 -5
View File
@@ -1,7 +1,7 @@
---
name: kvb-cleanup
description: >
Free disk space agents filled: ask peer sessions what they still need,
Free disk space agents filled: ask peers what they still need,
inventory /tmp, Docker and agent dirs, and delete only what the operator
picks. Trigger on /kvb-cleanup, "aufräumen", "bereinigen", "platte voll", "no
space left on device".
@@ -60,6 +60,9 @@ Read-only, in parallel, sizes included:
gh, buildx), worktrees with `git status --short` and whether their branch is
merged or pushed, Ansible and build temp dirs. With `pkgmgr` installed,
merged branches and worktrees go to the `kvb-git-branch-gc` skill instead.
- **Register**: `rasci.py artifacts` of the `kvb-org-artefacts` skill:
workdir, branch and artifacts of every task in the `kvb-org-rasci` register,
with its status and worker; `--ended` narrows it to the candidates.
- **Live use**: open handles (`fuser`, `lsof`) and running processes by name
only (`ps -o pid,etime,comm`). Never print full command lines or
environments: process arguments carry credentials. A sandboxed agent often
@@ -81,12 +84,15 @@ worktree or compose project name, a container label, an open handle, or
`unknown`. Then apply, first match wins:
1. Claimed by a peer: `keep`.
2. A job running on it (deploy, gate, test, swarm run, build): `keep
2. Listed in the register under a task that is not `done`, `stopped` or `stale`:
`keep (task <task>)`. Under an ended task it is a candidate whose owner is
the task's worker.
3. A job running on it (deploy, gate, test, swarm run, build): `keep
(running)`.
3. Attributable to a peer that has not answered: `keep (no answer from
4. Attributable to a peer that has not answered: `keep (no answer from
<peer>)`. Silence is not consent.
4. Archive duty: `archive first`.
5. Everything else is a candidate with a risk class:
5. Archive duty: `archive first`.
6. Everything else is a candidate with a risk class:
| Risk | Meaning | Typical items |
| ---- | ------- | ------------- |
@@ -157,6 +163,8 @@ ticked items. Revise repeats call 1; nothing is deleted before `Execute`.
- Idle running stacks go down through the project's teardown target, never by
removing their containers or volumes underneath them.
- Archive-first items: copy out, verify the copy, then delete.
- Drop every deleted item from its task as the `kvb-org-artefacts` skill
says (`set <task> --drop-artifact <kind>:<ref>`), so `artifacts` stays true.
- A delete failing on permissions (files owned by a container UID): use the
project's container helper. Never sudo, never bypass the sandbox; without a
helper, report the path.
+1 -1
View File
@@ -2,7 +2,7 @@
name: kvb-code-test
description: >
Discover the Makefile's test targets, let the operator pick, run and report;
fix mode loops until green. Trigger on /kvb-code-test, /kvb-code-test --fix, or when the
fix mode loops until green. Trigger on /kvb-code-test [--fix], or when the
operator asks to run the tests, make them pass, or get the suite green.
---
+5 -3
View File
@@ -2,8 +2,8 @@
name: kvb-code-tune
description: >
Find and apply performance optimizations (CI, deploy, build, gate, hot
paths) without reducing logic, coverage or correctness. Trigger on /kvb-code-tune,
when a job dies on its time budget, when a pipeline or deploy grows slower,
paths) without reducing logic, coverage or correctness. Trigger on
/kvb-code-tune, a job dying on its time budget, a slower pipeline or deploy,
or asked where time goes.
---
@@ -113,7 +113,9 @@ every agent session on this machine, not only the branch lineage.
- **The first to identify a cluster works it.** "First" is the `kvb-org-organize`
claim `cluster/<slug>`, whose `--work` names the cluster's log line and
number; between machines, the earliest announcement by message time. A
claim without a measurement does not count.
claim without a measurement does not count. In the `kvb-org-rasci` register
the cluster's key result is the measured target (`<step> from <before> to
under <after>`), met only by the re-measurement.
- **Someone was earlier:** send them your measurements and findings, then move
on to the next cluster. Never build a second optimization of the same
cluster.
+3 -3
View File
@@ -2,9 +2,9 @@
name: kvb-create-feature
description: >
Derive missing work (principles pass for structural flaws, usability pass for
missing features) and write requirement documents after single-select
confirmation. Trigger on /kvb-create-feature, "was fehlt noch", "welche features
brauchen wir", or when the operator asks what to build next.
missing features) and write requirement documents after confirmation.
Trigger on /kvb-create-feature, "was fehlt noch", "welche features brauchen
wir", or asked what to build next.
---
Two blind spots produce different work: the code has defects of structure,
+5 -3
View File
@@ -30,9 +30,11 @@ verified, and an unverifiable plan cannot be handed to a robot.
trade-off the plan rests on gets settled before execution, escalating to the
`kvb-think-dialectic` skill where being wrong is expensive. The robot does not ask, so
an unresolved decision becomes a guess at runtime.
6. **Record the plan as a todo list.** Write the sub-objectives into the
harness's own todo tracking, one entry per item, phrased as the outcome. Keep
a plan file only when the operator asks for one.
6. **Record the plan in the register.** Enter the objective in the
`kvb-org-rasci` register with each sub-objective's verification as a key
result, and mirror the sub-objectives into the harness's own todo tracking,
one entry per item, phrased as the outcome. Keep a plan file only when the
operator asks for one.
7. **Present it and wait.** Show the objective, the ordered sub-objectives with
their verifications, what is deliberately out of scope, and the assumptions
the plan rests on. Stop here: the plan is the deliverable of this step.
+4
View File
@@ -81,6 +81,10 @@ Then ask with `AskUserQuestion`:
(still in use elsewhere), or `failed: <why>`.
Report a failure as it stands; never remove by hand what gc skipped or
refused.
4. For every `done` item, drop its worktree and branch from the task that
lists them in the `kvb-org-rasci` register (`rasci.py artifacts` names
it): `set <task> --drop-artifact worktree:<path>`, and `--branch ''` where
the branch is gone.
## Step 5: unmerged and dirty
+31
View File
@@ -0,0 +1,31 @@
---
name: kvb-org-artefacts
description: >
Artifacts of the kvb-org-rasci register per task: images, volumes,
worktrees. Trigger on /kvb-org-artefacts, "artefakte".
---
Artifacts live in the same table as the tasks of the `kvb-org-rasci`
register: each one belongs to exactly one task, carries its kind, reference
and the time it was recorded, and takes status and worker from its task. The
script is the register's `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py`.
```bash
rasci.py set <task> --artifact <kind>:<ref>
rasci.py set <task> --drop-artifact <kind>:<ref>
rasci.py artifacts [--ended] [--task <task>]
```
Kinds: `image:`, `container:`, `volume:`, `network:`, `stack:`, `worktree:`,
`dir:`, `log:`. `artifacts` lists each task's workdir and branch beside them.
## Rules
- Record an artifact the moment it exists, under the task that created it;
one left unrecorded is invisible to cleanup.
- `artifacts --ended` lists what belongs to `done`, `stopped` or `stale`
tasks: the cleanup candidates, owned by the task's worker. Everything of an
open task stays.
- Deleting goes through the `kvb-cleanup` skill, where the operator picks;
drop the artifact here once it is gone.
- The register's `show` counts each task's artifacts in its Artifacts column.
+3 -2
View File
@@ -7,5 +7,6 @@ description: >
---
Run the `kvb-org-organize` skill's **Lineage** and **Peer protocol** sections for the
failure or fix at hand. They hold the whole procedure; this skill only keeps
`/kvb-org-coordinate` working.
failure or fix at hand. They hold the whole procedure, including the entries
in the `kvb-org-rasci` register; this skill only keeps `/kvb-org-coordinate`
working.
+1 -1
View File
@@ -15,7 +15,7 @@ decides what to act on after reading the matrix.
Gather in parallel, skip any source the session does not have, and never invent
a row for a source you could not read:
1. **Tasks** — the session's todo list, the operator's stated goals this
1. **Tasks** — the `kvb-org-rasci` register first, then the session's todo list, the operator's stated goals this
session, and any requirement or plan documents the work follows.
2. **Working tree** — `git status --short` and `git log --oneline -10`:
uncommitted files are open work, commits since the branch point are closed
+16 -6
View File
@@ -53,8 +53,10 @@ directory with `--session <id>`.
worktree>"`. Exit 0: elected, or already yours. Exit 3: a live agent holds
it, and its claim is printed. Exit 4: the holder's session is dead (see
Succession).
4. **Elected**: continue with the Peer protocol; its message is the
announcement.
4. **Elected**: the claim entered the task in the `kvb-org-rasci` register,
the organisation's single point of truth; complete it there (objective,
description, priority, roles), then continue with the Peer protocol; its
message is the announcement.
5. **Not elected**: send the holder your findings and stop on that work.
A Claude Code session cannot edit a file in a git worktree before it holds a
@@ -74,8 +76,9 @@ records the result.
- Before starting anything that consumes shared capacity (gates, deploys,
iteration loops, swarm or cloud resources, cleanup of shared data), an agent
sends the chair what, where, for how long and its expected disk need, waits
for the grant, and reports the release. The chair keeps one queue, answers
every request with its position, enforces the limits the operator set,
for the grant, and reports the release. The chair keeps one queue as the
priorities in the `kvb-org-rasci` register, answers every request with its
position, enforces the limits the operator set,
checks every grant with `watch.py admit --need-gb <N>` (or `--kind <slot>`;
test kinds also need CPU and RAM below the configured limits).
- A running job (deploy, gate, test, swarm run, build) is never cut short to
@@ -154,14 +157,18 @@ The one protocol for every exchange between agents; `kvb-org-coordinate`, `kvb-c
evidence (failing line quoted literally, run id, measurement), the
suspected cause marked as a thesis, and the question "Already known or
fixed on your side? Reply with a SHA or branch, or 'none'." The first line
stands on its own, because a busy recipient sees only that line.
stands on its own, because a busy recipient sees only that line. In the
`kvb-org-rasci` register the peers asked are C, the chair that grants
capacity is S.
4. **Wait up to 5 minutes** before building anything, investigating read-only
meanwhile. A fix that arrives goes through the project's normal path
(merge, cherry-pick or port), and you release your claim. Silence is
neither a "no" nor a competing claim: after 5 minutes, proceed.
5. **Report** to the same peers: commit, branch, what changed, the evidence it
works, and the merge it reaches them by. Report an abandoned or failed
attempt too. Then release the claim.
attempt too. In the register, enter the peers told as I and mark every key
result the evidence proves as met. Then release the claim: the task ends
`done`, or `stopped` while a key result is open.
- Never invent a peer's answer.
- A peer's report is not evidence: read the SHA, log or artifact it names
@@ -199,3 +206,6 @@ On /kvb-org-organize, one table of the current offices from `council.py list`, s
claims marked:
| Office | Holder | Since | Work | Grants or queue |
Who carries which task, with priority and status, is the `kvb-org-rasci`
register's `show`.
+42 -3
View File
@@ -16,10 +16,12 @@ 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
@@ -28,6 +30,7 @@ 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]:
@@ -83,7 +86,7 @@ def claims() -> list[tuple[str, dict]]:
return found
def write_claim(path: Path, office: str, sid: str, runtime: str, work: str) -> None:
def write_claim(path: Path, office: str, sid: str, runtime: str, work: str) -> dict:
claim = {
"office": office,
"session": sid,
@@ -94,6 +97,30 @@ def write_claim(path: Path, office: str, sid: str, runtime: str, work: str) -> N
"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:
@@ -105,12 +132,21 @@ def cmd_claim(args: argparse.Namespace) -> int:
except FileExistsError:
claim = read_claim(path)
if claim is not None and claim["session"] == sid:
write_claim(path, args.office, sid, runtime, args.work)
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
write_claim(path, args.office, sid, runtime, args.work)
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
@@ -121,6 +157,8 @@ def cmd_takeover(args: argparse.Namespace) -> int:
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():
@@ -145,6 +183,7 @@ def cmd_release(args: argparse.Namespace) -> int:
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
+2
View File
@@ -31,6 +31,8 @@ Fable 5.1 $10 / $50.
2. Start the brief with the line `task-class: <class>`; the spawn hook logs
it together with `model`, `effort` and the Agent `description`.
3. Set `model` and `effort` from the row; for `judgement` set neither.
4. Enter the spawn in the `kvb-org-rasci` register: the subagent as R and as
`--worker "subagent <type> (<model>, <effort>)"` of the task it serves.
- An agent type whose definition sets its own model (Explore, Plan, the
cavecrew agents) keeps it; never pass `model` or `effort` for it.
+60
View File
@@ -0,0 +1,60 @@
---
name: kvb-org-rasci
description: >
RASCI/OKR register of tasks: roles, key results, worker, status.
Trigger on /kvb-org-rasci, "rasci", "raci", "zuständigkeiten".
---
The register is the single point of truth of the organisation: every agent
records here what it works on, and claims, queue, handover and reports run
over it. `${CLAUDE_SKILL_DIR}/scripts/rasci.py` keeps it in
`<council root>/rasci.json`. A `kvb-org-organize` claim enters its task by
itself (holder as R and A, `running`, start, workdir, branch); its release
sets `done` and the end, or `stopped` while a key result is still open.
```bash
rasci.py set <task> --title <t> --objective <o> --description <d> --prio <n> --status queued|running|blocked|done|stopped --worker "<agent | subagent <type> (<model>)>" --workdir <dir> --branch <b> --artifact <kind>:<ref> --tool <script or command> [--start <iso>] [--end <iso>] [--drop-artifact <a>] [--drop-tool <t>]
rasci.py kr <task> add "<measurable result>" | met <n> | open <n> | rm <n>
rasci.py role <task> "<party>" <letters>
rasci.py get <task>
rasci.py rm <task>
rasci.py show [--all]
rasci.py artifacts [--ended] [--task <task>]
rasci.py tools
```
## Objective and key results
The objective says what the task is for; its key results are the measurable
outcomes that prove it reached (a test green, a run passing, a release
published). `done` is refused while one is open; a task ended short of them is
`stopped`. `show` puts both in the OKR column as `<objective> (<met>/<all> KR)`.
## Roles
- **R Responsible**: does the task: the claim holder, or the subagent it runs.
- **A Accountable**: exactly one; takes the final decision and carries the
overall responsibility. The claim holder, unless the operator decides.
- **S Supportive**: actively helps carry it out: a peer's fix, the chair's
grant, a stack or runner.
- **C Consulted**: asked for input before acting: line representatives,
lineage owners, the operator on a pending decision.
- **I Informed**: kept up to date on progress or result, without a say.
`-` clears a party; the operator is the party `Operator`.
## Rules
- On start, `set` title, objective, description, priority and worker, `kr
add` every key result, and `role` every party beyond R and A; on each
change, update the status. The worker is the one session or subagent
executing the task right now.
- Artifacts share this table and follow the `kvb-org-artefacts` skill:
record each one when it appears, drop it once deleted. Add every script or
tool the task ran (`--tool`): `tools` ranks them for `kvb-skills-dream`.
- Priority: the operator's order first, then the chair's queue; the chair
writes it.
- Write only your own rows; another session's task belongs to that session.
- On /kvb-org-rasci print `show` unchanged; its `gaps:` line names tasks
without exactly one A, without R, or open without a key result. `get`
prints one task in full, key results numbered.
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""Keep the RASCI register of the organisation's tasks in a local JSON file.
Environment:
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
the register is <root>/rasci.json, and `show` also lists every work/
and cluster/ claim of the council there. /tmp/agents-council unless set.
Exit codes:
0 done, 2 usage error.
"""
from __future__ import annotations
import argparse
import contextlib
import datetime
import fcntl
import importlib.util
import json
import os
import re
import sys
import tempfile
from collections.abc import Iterator
from pathlib import Path
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
DB = ROOT / "rasci.json"
LOCK = ROOT / "rasci.lock"
COUNCIL = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts" / "council.py"
TASK = re.compile(r"^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$")
LETTERS = re.compile(r"^(?!.*(.).*\1)[RASCI]+$")
ORDER = "RASCI"
STATUSES = ("queued", "running", "blocked", "done", "stopped")
ENDED = ("done", "stopped")
FIELDS = (
"title", "objective", "description", "prio", "status", "worker", "start", "end", "workdir",
"branch",
)
TEXT_FIELDS = ("title", "objective", "description", "worker", "workdir", "branch")
LIST_FIELDS = {"tools": "tool"}
ARTIFACT = re.compile(r"^[a-z]+:\S")
OPERATOR = "Operator"
def fail(message: str) -> None:
print(f"rasci: {message}", file=sys.stderr)
sys.exit(2)
def check_task(task: str) -> None:
if not TASK.match(task):
fail(f"invalid task {task!r}; use a slug like work/<slug>")
def stamp(value: str = "now") -> str:
if value == "now":
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
try:
parsed = datetime.datetime.fromisoformat(value)
except ValueError:
fail(f"invalid time {value!r}; use ISO 8601 or now")
if parsed.tzinfo is None:
parsed = parsed.astimezone()
return parsed.astimezone(datetime.UTC).isoformat(timespec="seconds")
def letters_of(*parts: str) -> str:
return "".join(letter for letter in ORDER if any(letter in part for part in parts))
def load() -> dict[str, dict]:
try:
return json.loads(DB.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except ValueError:
fail(f"{DB} is not valid JSON")
@contextlib.contextmanager
def database() -> Iterator[dict[str, dict]]:
ROOT.mkdir(parents=True, exist_ok=True)
with LOCK.open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
tasks = load()
yield tasks
with tempfile.NamedTemporaryFile(
"w", dir=ROOT, prefix=".rasci-", delete=False, encoding="utf-8"
) as tmp:
json.dump(tasks, tmp, indent=1, sort_keys=True)
os.replace(tmp.name, DB)
def open_results(entry: dict) -> int:
return sum(not kr["met"] for kr in entry.get("key_results", []))
def apply_status(entry: dict, status: str) -> None:
if status not in ENDED:
entry.pop("end", None)
entry["status"] = status
if status == "running":
entry.setdefault("start", stamp())
if status in ENDED:
entry.setdefault("end", stamp())
def track(task: str, holder: str, status: str, *, description: str | None = None,
previous: str | None = None, workdir: str | None = None,
branch: str | None = None) -> None:
"""Write a council claim (status running) or release (status done) through.
Args:
task: the claimed office, e.g. work/<slug>.
holder: the session holding the claim.
status: running on claim and takeover, done on release; a release with
open key results ends as stopped.
description: the claim's --work line, kept unless one is recorded.
previous: the stale holder a takeover replaces; loses its R and A.
workdir: the claiming process's directory, kept unless one is recorded.
branch: the checked-out branch there, kept unless one is recorded.
"""
with database() as tasks:
entry = tasks.setdefault(task, {"roles": {}})
roles = entry.setdefault("roles", {})
if previous and previous != holder:
left = roles.pop(previous, "").replace("R", "").replace("A", "")
if left:
roles[previous] = left
if entry.get("worker") == previous:
entry["worker"] = holder
entry.setdefault("worker", holder)
for field, value in (("description", description), ("workdir", workdir), ("branch", branch)):
if value:
entry.setdefault(field, value)
if status == "done" and open_results(entry):
status = "stopped"
if status in ENDED or entry.get("status") in (None, "queued", *ENDED):
apply_status(entry, status)
if status not in ENDED:
accountable = any("A" in v for p, v in roles.items() if p != holder)
roles[holder] = letters_of(roles.get(holder, ""), "R" if accountable else "RA")
def claimed() -> dict[str, dict]:
if not COUNCIL.is_file():
return {}
spec = importlib.util.spec_from_file_location("council", COUNCIL)
council = importlib.util.module_from_spec(spec)
spec.loader.exec_module(council)
live = council.live_sessions()
rows = {}
for office, claim in council.claims():
if not office.startswith(("work/", "cluster/")):
continue
holder = claim["name"] or claim["session"]
rows[office] = {
"description": claim["work"],
"status": "claimed",
"worker": holder,
"start": claim["since"],
"roles": {holder: "RA"},
"stale": council.is_stale(claim, live),
}
return rows
def merged(include_done: bool) -> dict[str, dict]:
rows = claimed()
for task, entry in load().items():
base = rows.get(task, {"status": "queued", "roles": {}})
fields = {k: v for k, v in entry.items() if k != "roles"}
rows[task] = {**base, **fields, "roles": {**base["roles"], **entry.get("roles", {})}}
for row in rows.values():
if row.get("stale"):
row["status"] = "stale"
return {
task: row for task, row in rows.items() if include_done or row["status"] not in ENDED
}
def cell(text: object, width: int) -> str:
text = " ".join(str(text).split()).replace("|", "\\|")
return text if len(text) <= width else text[: width - 1] + "…"
def when(value: str | None) -> str:
if not value:
return "-"
return datetime.datetime.fromisoformat(value).astimezone().strftime("%m-%d %H:%M")
def gaps(task: str, row: dict) -> list[str]:
found = []
roles = row["roles"]
accountable = sum("A" in letters for letters in roles.values())
if accountable != 1:
found.append(f"{task} has {accountable} A")
if not any("R" in letters for letters in roles.values()):
found.append(f"{task} has no R")
if not row.get("key_results") and row["status"] not in ENDED:
found.append(f"{task} has no key result")
return found
def cmd_set(args: argparse.Namespace) -> int:
check_task(args.task)
if args.prio is not None and args.prio < 1:
fail("--prio must be 1 or higher")
times = {f: stamp(getattr(args, f)) for f in ("start", "end") if getattr(args, f)}
for ref in args.artifact:
if not ARTIFACT.match(ref):
fail(f"invalid artifact {ref!r}; use <kind>:<ref>, e.g. image:<name>:<tag>")
with database() as tasks:
entry = tasks.setdefault(args.task, {"roles": {}})
if args.status == "done" and open_results(entry):
fail(f"{args.task}: {open_results(entry)} key result(s) open; mark them met or set stopped")
for field in (*TEXT_FIELDS, "prio"):
value = getattr(args, field)
if value is not None:
entry[field] = value
if args.status:
apply_status(entry, args.status)
entry.update(times)
for field, flag in LIST_FIELDS.items():
drop = getattr(args, f"drop_{flag}")
items = [i for i in entry.get(field, []) if i not in drop]
items += [i for i in dict.fromkeys(getattr(args, flag)) if i not in items]
if items:
entry[field] = items
else:
entry.pop(field, None)
kept = [a for a in entry.get("artifacts", []) if a["ref"] not in args.drop_artifact]
known = {a["ref"] for a in kept}
kept += [{"ref": r, "added": stamp()} for r in dict.fromkeys(args.artifact) if r not in known]
if kept:
entry["artifacts"] = kept
else:
entry.pop("artifacts", None)
print(f"set {args.task}")
return 0
def cmd_role(args: argparse.Namespace) -> int:
check_task(args.task)
letters = args.letters.upper()
if letters != "-" and not LETTERS.match(letters):
fail("letters must be distinct ones of R, A, S, C, I, or - to clear")
with database() as tasks:
roles = tasks.setdefault(args.task, {"roles": {}}).setdefault("roles", {})
if letters == "-":
roles.pop(args.party, None)
else:
roles[args.party] = letters_of(letters)
print(f"{args.task}: {args.party} {letters}")
return 0
def cmd_kr(args: argparse.Namespace) -> int:
check_task(args.task)
with database() as tasks:
entry = tasks.setdefault(args.task, {"roles": {}})
results = entry.setdefault("key_results", [])
if args.action == "add":
if all(kr["text"] != args.value for kr in results):
results.append({"text": args.value, "met": False})
else:
if not args.value.isdigit() or not 1 <= int(args.value) <= len(results):
fail(f"{args.task}: no key result {args.value!r}; `get` lists them by number")
index = int(args.value) - 1
if args.action == "rm":
results.pop(index)
else:
results[index]["met"] = args.action == "met"
if not results:
entry.pop("key_results")
print(f"{args.task}: key result {args.action} {args.value}")
return 0
def cmd_get(args: argparse.Namespace) -> int:
row = merged(True).get(args.task)
if row is None:
fail(f"{args.task}: not in the register")
print(f"task: {args.task}")
for field in FIELDS:
if row.get(field) not in (None, ""):
print(f"{field}: {row[field]}")
for number, kr in enumerate(row.get("key_results", []), 1):
print(f"key result {number} [{'x' if kr['met'] else ' '}]: {kr['text']}")
for artifact in row.get("artifacts", []):
print(f"artifact: {artifact['ref']} (since {when(artifact['added'])})")
for field, flag in LIST_FIELDS.items():
for item in row.get(field, []):
print(f"{flag}: {item}")
roles = ", ".join(f"{p}={v}" for p, v in sorted(row["roles"].items()))
print(f"roles: {roles or '-'}")
return 0
def cmd_artifacts(args: argparse.Namespace) -> int:
rows = merged(True)
for task in sorted(rows):
row = rows[task]
if args.task and task != args.task:
continue
if args.ended and row["status"] not in (*ENDED, "stale"):
continue
places = [(f"{f}:{row[f]}", None) for f in ("workdir", "branch") if row.get(f)]
recorded = [(a["ref"], a["added"]) for a in row.get("artifacts", [])]
for ref, added in (*places, *recorded):
print(f"{row['status']}\t{task}\t{row.get('worker') or '-'}\t{ref}\t{when(added)}")
return 0
def cmd_tools(_: argparse.Namespace) -> int:
used: dict[str, list[str]] = {}
for task, row in merged(True).items():
for tool in row.get("tools", []):
used.setdefault(tool, []).append(task)
for tool, tasks in sorted(used.items(), key=lambda item: (-len(item[1]), item[0])):
print(f"{len(tasks)}\t{tool}\t{' '.join(sorted(tasks))}")
return 0
def cmd_rm(args: argparse.Namespace) -> int:
check_task(args.task)
with database() as tasks:
removed = tasks.pop(args.task, None)
print(f"removed {args.task}" if removed is not None else f"{args.task}: not in the register")
return 0
def cmd_show(args: argparse.Namespace) -> int:
rows = merged(args.all)
if not rows:
print("no tasks")
return 0
order = sorted(rows, key=lambda t: (rows[t].get("prio") is None, rows[t].get("prio") or 0, t))
header = ["Prio", "Task", "OKR", "Status", "Worker", "Artifacts", "Start", "End", *ORDER]
print("| " + " | ".join(header) + " |")
print("|" + "---|" * len(header))
problems = []
for task in order:
row = rows[task]
title = f"{task}: {row['title']}" if row.get("title") else task
results = row.get("key_results", [])
okr = cell(row.get("objective") or "-", 40)
if results:
okr += f" ({sum(kr['met'] for kr in results)}/{len(results)} KR)"
values = [
row.get("prio") or "-",
cell(title, 50),
okr,
row["status"],
cell(row.get("worker") or "-", 30),
len(row.get("artifacts", [])) or "-",
when(row.get("start")),
when(row.get("end")),
]
parties = sorted(row["roles"], key=lambda p: (p != OPERATOR, p.lower()))
values += [
cell(", ".join(p for p in parties if letter in row["roles"][p]), 30) for letter in ORDER
]
print("| " + " | ".join(str(v) for v in values) + " |")
problems += gaps(task, row)
if problems:
print("gaps: " + "; ".join(problems))
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("set")
p.add_argument("task")
for field in (*TEXT_FIELDS, "start", "end"):
p.add_argument(f"--{field}")
p.add_argument("--prio", type=int)
for flag in (*LIST_FIELDS.values(), "artifact"):
p.add_argument(f"--{flag}", action="append", default=[])
p.add_argument(f"--drop-{flag}", action="append", default=[])
p.add_argument("--status", choices=STATUSES)
p.set_defaults(func=cmd_set)
p = sub.add_parser("kr")
p.add_argument("task")
p.add_argument("action", choices=("add", "met", "open", "rm"))
p.add_argument("value")
p.set_defaults(func=cmd_kr)
p = sub.add_parser("role")
p.add_argument("task")
p.add_argument("party")
p.add_argument("letters")
p.set_defaults(func=cmd_role)
for name, func in (("get", cmd_get), ("rm", cmd_rm)):
p = sub.add_parser(name)
p.add_argument("task")
p.set_defaults(func=func)
p = sub.add_parser("show")
p.add_argument("--all", action="store_true")
p.set_defaults(func=cmd_show)
p = sub.add_parser("artifacts")
p.add_argument("--ended", action="store_true")
p.add_argument("--task")
p.set_defaults(func=cmd_artifacts)
sub.add_parser("tools").set_defaults(func=cmd_tools)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -28,6 +28,10 @@ Operate as an autonomous robot: given a goal, reach it without hand-holding.
- **Verify, do not assume.** Reaching the goal means proving it: run it, read the
output, confirm the end state. Never report success on a step you did not
actually verify.
- **Run it over the register.** Before the first step, enter the goal in the
`kvb-org-rasci` register as the task's objective and every check that proves
it as a key result; mark each one met as its proof comes in. The run is
finished when the task can be set `done`.
## Rules
+7 -3
View File
@@ -21,7 +21,8 @@ in a sidetree until it does, or leaves as a proposal.
## The stages
1. **Brief.** One concern per agent, on the model the `kvb-org-perform` skill
picks. Name the goal, the exact base commit, the
picks, entered in the `kvb-org-rasci` register as R with `--worker "subagent
<type> (<model>)"`. Name the goal, the exact base commit, the
scope that may change, the evidence expected back, a time cap (default
60 minutes) at which it reports its interim state, and what the agent must
not do: no pushes, no gates or deploys
@@ -35,7 +36,9 @@ in a sidetree until it does, or leaves as a proposal.
behaviour, and makes a negative control: the test fails without the change.
Numbers come from the same source before and after.
4. **Self-assessment.** The agent reports fix confidence and break risk with
their residuals and commits on its branch.
their residuals and commits on its branch. Its parent marks the key results
that evidence proves as met in the `kvb-org-rasci` register and records
the branch and worktree as `--branch` and `--artifact worktree:<path>`.
5. **Independent review.** Before anything is merged, run the `kvb-think-dialectic` skill
with skeptics who did not write the change. A hole they find returns the
change to stage 3.
@@ -67,7 +70,8 @@ and verify each step instead of assuming it:
only once every one of its commits is reachable from a pushed branch.
6. **Release what it held**: slots (reported to the resource chair), port or
subnet reservations, locks, its `kvb-org-organize` claims and every claim
announced to other agents.
announced to other agents. Drop each removed item from the register with
`--drop-artifact` and set the subagent's task `done` or `stopped`.
7. **Remove its temporary files**: logs, message files and scratch data that
have served their purpose. Keep only the evidence the report cites, until
the report is delivered.
+3 -4
View File
@@ -1,10 +1,9 @@
---
name: kvb-skills-auto
description: >
Turn operator repetition into a skill. Trigger when the operator gives the
same instruction, correction or prompt pattern about the third time in a
conversation, or says they keep repeating themselves, and the pattern is
reusable.
Turn operator repetition into a skill. Trigger when the operator repeats an
instruction, correction or prompt pattern a third time in a conversation, or
says they keep repeating themselves, and the pattern is reusable.
---
Its objective is the `kvb-skills-tune` skill's **Objective** section: a skill is worth
+5 -2
View File
@@ -3,8 +3,8 @@ name: kvb-skills-dream
description: >
Consolidate the last 24 hours of work in this repository: what held, what
broke, which hypotheses follow, into memory and improvement proposals.
Trigger on /kvb-skills-dream or when the operator asks to reflect on or learn from
recent work.
Trigger on /kvb-skills-dream or a request to reflect on or learn from recent
work.
---
Sleep on the work: replay what happened in this repository, separate what held
@@ -48,6 +48,9 @@ Gather the window's actual record, not your recollection of it:
logs of the red ones — the failing log line, not the job title.
- **Reviews**: pull requests and issues touched in the window, and the review
comments on them.
- **Register**: the `kvb-org-rasci` register: `rasci.py tools` ranks the
scripts and tools the window's tasks ran, the refactoring candidates;
`get <task>` shows a task's objective, roles, workdir and artifacts.
- **Memory**: this project's memory directory and its `MEMORY.md` index — the
findings already recorded, so the dream extends them instead of repeating them.
- **Instructions**: `CLAUDE.md`, `AGENTS.md`, and the project's own skills, to
+3 -3
View File
@@ -2,9 +2,9 @@
name: kvb-think-criticize
description: >
Give constructive criticism of a problem, plan, design, decision, text or
code from diverse aspects, each point hardened by dialectic. Trigger on
/kvb-think-criticize, "kritisiere", "kritisch betrachten", "konstruktive Kritik", "was
haeltst du davon", or requests for honest feedback.
code, each point hardened by dialectic. Trigger on /kvb-think-criticize,
"kritisiere", "kritisch betrachten", "konstruktive Kritik", "was haeltst du
davon", or requests for honest feedback.
---
The stance is critical but constructive: the goal is a better result, not a
+227
View File
@@ -0,0 +1,227 @@
"""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()