diff --git a/README.md b/README.md index f86a673..6a3a142 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ flowchart LR org --> org_matrix[kvb-org-matrix] org --> org_rasci[kvb-org-rasci] org --> org_artefacts[kvb-org-artefacts] + org --> org_wsjf[kvb-org-wsjf] org --> org_robot[kvb-org-robot] skills --> skills_dream[kvb-skills-dream] skills --> skills_tune[kvb-skills-tune] @@ -128,6 +129,7 @@ flowchart LR status_matrix[kvb-org-matrix] rasci[kvb-org-rasci] artefacts[kvb-org-artefacts] + wsjf[kvb-org-wsjf] robot[kvb-org-robot] end subgraph cat_skills [skills] @@ -176,6 +178,8 @@ flowchart LR status_matrix --> rasci cleanup --> artefacts artefacts --> rasci + democracy --> wsjf + wsjf --> rasci dream --> rasci observe --> triage diff --git a/skills/kvb-code-test/SKILL.md b/skills/kvb-code-test/SKILL.md index 66be662..d1c2d73 100644 --- a/skills/kvb-code-test/SKILL.md +++ b/skills/kvb-code-test/SKILL.md @@ -2,8 +2,8 @@ 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 [--fix], or when the - operator asks to run the tests, make them pass, or get the suite green. + fix mode loops until green. Trigger on /kvb-code-test [--fix], or asked to + run the tests, make them pass, or get the suite green. --- The Makefile is the source of truth for how this project runs tests. Never diff --git a/skills/kvb-git-commit-next/SKILL.md b/skills/kvb-git-commit-next/SKILL.md index 8f66fb4..72f7b7e 100644 --- a/skills/kvb-git-commit-next/SKILL.md +++ b/skills/kvb-git-commit-next/SKILL.md @@ -3,7 +3,7 @@ name: kvb-git-commit-next description: > Check every branch for the staged fix, commit the staged bundle with --no-verify, then stage and brief the next bundle. Trigger on /kvb-git-commit-next - or when the operator asks to commit and stage the next bundle in one step. + or a request to commit and stage the next bundle in one step. --- 1. `git fetch --all --prune --tags`, then hunt the staged fix across every diff --git a/skills/kvb-org-organize/SKILL.md b/skills/kvb-org-organize/SKILL.md index 40a81d5..b1db830 100644 --- a/skills/kvb-org-organize/SKILL.md +++ b/skills/kvb-org-organize/SKILL.md @@ -76,9 +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 as the - priorities in the `kvb-org-rasci` register, answers every request with its - position, enforces the limits the operator set, + for the grant, and reports the release. The chair keeps one queue, ordered + by the WSJF rank of the `kvb-org-wsjf` skill (it asks its subordinates for + the Fibonacci estimates), answers every request with its position, enforces the limits the operator set, checks every grant with `watch.py admit --need-gb ` (or `--kind `; 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 diff --git a/skills/kvb-org-perform/SKILL.md b/skills/kvb-org-perform/SKILL.md index a552360..b18c3ad 100644 --- a/skills/kvb-org-perform/SKILL.md +++ b/skills/kvb-org-perform/SKILL.md @@ -1,8 +1,8 @@ --- name: kvb-org-perform description: > - Pick `model` and `effort` per subagent from a fixed table: cheaper only on a - clear match at 90 % that the result equals the default's; log spawns and + Pick `model` and `effort` per subagent from a fixed table: cheaper only at + 90 % confidence that the result equals the default's; log spawns and quality. Apply AUTOMATICALLY before every Agent tool call. --- diff --git a/skills/kvb-org-rasci/SKILL.md b/skills/kvb-org-rasci/SKILL.md index 9ecd6db..3e38c42 100644 --- a/skills/kvb-org-rasci/SKILL.md +++ b/skills/kvb-org-rasci/SKILL.md @@ -20,6 +20,8 @@ rasci.py get rasci.py rm rasci.py show [--all] rasci.py artifacts [--ended] [--task ] +rasci.py estimate "" --value --complexity +rasci.py wsjf [--all] rasci.py tools ``` @@ -52,8 +54,9 @@ published). `done` is refused while one is open; a task ended short of them is - 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. +- Priority is the rank of the `kvb-org-wsjf` skill: an operator's pin + (`--prio`) first, then WSJF from the estimates the chair collects; `show` + prints the rank as Prio and the score as WSJF. - 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` diff --git a/skills/kvb-org-rasci/scripts/rasci.py b/skills/kvb-org-rasci/scripts/rasci.py index 53f3198..5ff9736 100755 --- a/skills/kvb-org-rasci/scripts/rasci.py +++ b/skills/kvb-org-rasci/scripts/rasci.py @@ -20,6 +20,7 @@ import importlib.util import json import os import re +import statistics import sys import tempfile from collections.abc import Iterator @@ -41,6 +42,7 @@ FIELDS = ( TEXT_FIELDS = ("title", "objective", "description", "worker", "workdir", "branch") LIST_FIELDS = {"tools": "tool"} ARTIFACT = re.compile(r"^[a-z]+:\S") +FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89) OPERATOR = "Operator" @@ -192,6 +194,33 @@ def when(value: str | None) -> str: return datetime.datetime.fromisoformat(value).astimezone().strftime("%m-%d %H:%M") +def wsjf(row: dict) -> tuple[float, float, float] | None: + estimates = list(row.get("estimates", {}).values()) + if not estimates: + return None + value = statistics.median(e["value"] for e in estimates) + complexity = statistics.median(e["complexity"] for e in estimates) + return value, complexity, round(value / complexity, 2) + + +def ranked(rows: dict[str, dict]) -> list[str]: + def key(task: str) -> tuple: + row = rows[task] + score = wsjf(row) + pinned = row.get("prio") + return (pinned is None, pinned or 0, score is None, -(score[2] if score else 0), task) + + return sorted(rows, key=key) + + +def okr(row: dict) -> str: + results = row.get("key_results", []) + text = cell(row.get("objective") or "-", 40) + if results: + text += f" ({sum(kr['met'] for kr in results)}/{len(results)} KR)" + return text + + def gaps(task: str, row: dict) -> list[str]: found = [] roles = row["roles"] @@ -297,6 +326,53 @@ def cmd_get(args: argparse.Namespace) -> int: print(f"{flag}: {item}") roles = ", ".join(f"{p}={v}" for p, v in sorted(row["roles"].items())) print(f"roles: {roles or '-'}") + for party, estimate in sorted(row.get("estimates", {}).items()): + print(f"estimate {party}: business value {estimate['value']}, complexity {estimate['complexity']}") + score = wsjf(row) + if score: + print(f"wsjf: {score[2]:g}") + return 0 + + +def cmd_estimate(args: argparse.Namespace) -> int: + check_task(args.task) + if not args.drop: + for name in ("value", "complexity"): + if getattr(args, name) not in FIBONACCI: + fail(f"--{name} must be one of {', '.join(map(str, FIBONACCI))}") + with database() as tasks: + entry = tasks.setdefault(args.task, {"roles": {}}) + estimates = entry.setdefault("estimates", {}) + if args.drop: + estimates.pop(args.party, None) + else: + estimates[args.party] = {"value": args.value, "complexity": args.complexity, "at": stamp()} + if not estimates: + entry.pop("estimates") + print(f"{args.task}: estimate {'dropped' if args.drop else 'recorded'} for {args.party}") + return 0 + + +def cmd_wsjf(args: argparse.Namespace) -> int: + rows = merged(args.all) + if not rows: + print("no tasks") + return 0 + header = ["Rank", "Task", "OKR", "Business value", "Complexity", "WSJF", "Estimators", "Status"] + print("| " + " | ".join(header) + " |") + print("|" + "---|" * len(header)) + unestimated = [] + for rank, task in enumerate(ranked(rows), 1): + row = rows[task] + score = wsjf(row) + if score is None: + unestimated.append(task) + figures = [f"{n:g}" for n in score] if score else ["-", "-", "-"] + estimators = ", ".join(sorted(row.get("estimates", {}), key=str.lower)) or "-" + values = [rank, task, okr(row), *figures, cell(estimators, 40), row["status"]] + print("| " + " | ".join(str(v) for v in values) + " |") + if unestimated: + print("unestimated: " + ", ".join(unestimated)) return 0 @@ -338,22 +414,19 @@ def cmd_show(args: argparse.Namespace) -> int: 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] + header = ["Prio", "WSJF", "Task", "OKR", "Status", "Worker", "Artifacts", "Start", "End", *ORDER] print("| " + " | ".join(header) + " |") print("|" + "---|" * len(header)) problems = [] - for task in order: + for rank, task in enumerate(ranked(rows), 1): 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)" + score = wsjf(row) values = [ - row.get("prio") or "-", + rank, + f"{score[2]:g}" if score else "-", cell(title, 50), - okr, + okr(row), row["status"], cell(row.get("worker") or "-", 30), len(row.get("artifacts", [])) or "-", @@ -384,6 +457,16 @@ def main() -> int: 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("estimate") + p.add_argument("task") + p.add_argument("party") + p.add_argument("--value", type=int) + p.add_argument("--complexity", type=int) + p.add_argument("--drop", action="store_true") + p.set_defaults(func=cmd_estimate) + p = sub.add_parser("wsjf") + p.add_argument("--all", action="store_true") + p.set_defaults(func=cmd_wsjf) p = sub.add_parser("kr") p.add_argument("task") p.add_argument("action", choices=("add", "met", "open", "rm")) diff --git a/skills/kvb-org-wsjf/SKILL.md b/skills/kvb-org-wsjf/SKILL.md new file mode 100644 index 0000000..81b80ab --- /dev/null +++ b/skills/kvb-org-wsjf/SKILL.md @@ -0,0 +1,40 @@ +--- +name: kvb-org-wsjf +description: > + WSJF priority from Fibonacci estimates. Trigger on /kvb-org-wsjf, "wsjf", + "priorisieren". +--- + +Weighted Shortest Job First ranks the tasks of the `kvb-org-rasci` register +by cost of delay over job size: here business value over complexity, each the +median of Fibonacci estimates (1, 2, 3, 5, 8, 13, 21, 34, 55, 89). The +estimates live in the register's table; `wsjf` is its WSJF view, one row per +task with its OKR and the task id the register uses, and the register's Prio +column is this rank. The script is +`${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py`. + +```bash +rasci.py estimate "" --value --complexity +rasci.py estimate "" --drop +rasci.py wsjf [--all] +``` + +## Procedure + +1. **The chair asks.** For every new task, and when its objective or a key + result changes, the resource chair of the `kvb-org-organize` skill asks + its subordinates (the task's worker and R, the representatives of the + lines it touches) for one Fibonacci estimate each of business value and + complexity, one message per agent. +2. **Record each answer** under the party that gave it; a new estimate + replaces the old one. An agent may record its own. +3. **Rank**: WSJF = median business value / median complexity, highest + first. An operator's pin (`rasci.py set --prio `) outranks every + score; unestimated tasks come last and are named under `unestimated:`. +4. **Grant in rank order**: the chair hands out capacity by this rank. + +## Rules + +- Estimate relative to the other open tasks, never in hours. +- Never invent an estimate for a party that did not answer; a task without + one stays unestimated. diff --git a/skills/kvb-skills-dream/SKILL.md b/skills/kvb-skills-dream/SKILL.md index 6385398..a9959da 100644 --- a/skills/kvb-skills-dream/SKILL.md +++ b/skills/kvb-skills-dream/SKILL.md @@ -1,8 +1,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. + Consolidate the last 24 hours of work in this repository (what held, what + broke) into memory and improvement proposals. Trigger on /kvb-skills-dream or a request to reflect on or learn from recent work. --- diff --git a/tests/test_rasci.py b/tests/test_rasci.py index f83e89a..e634fc7 100644 --- a/tests/test_rasci.py +++ b/tests/test_rasci.py @@ -14,7 +14,7 @@ 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 |" +HEADER = "| Prio | WSJF | Task | OKR | Status | Worker | Artifacts | Start | End | R | A | S | C | I |" def local(iso: str) -> str: @@ -81,11 +81,11 @@ class TestRasci(unittest.TestCase): lines = self._out("show") self.assertEqual(lines[0], HEADER) self.assertEqual( - lines[2], "| 1 | work/b: Beta | - | queued | subagent Explore (haiku) | - | - | - | Pkgmgr | | | | |" + 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)} | - " + f"| 2 | - | work/a: Alpha | Ship A | running | Skills | - | {local(start)} | - " "| Skills | Skills | | Operator, Pkgmgr | Operator |", ) self.assertEqual( @@ -159,9 +159,9 @@ class TestRasci(unittest.TestCase): 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].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.assertTrue(lines[3].startswith("| 2 | - | cluster/dead | - | stale | Gone |")) self.assertFalse(any("chair" in line for line in lines)) def test_council_claim_takeover_and_release_write_through(self): @@ -203,6 +203,8 @@ class TestRasci(unittest.TestCase): ("get", "work/none"), ("kr", "work/a", "met", "1"), ("set", "work/a", "--artifact", "no-kind"), + ("estimate", "work/a", "One", "--value", "4", "--complexity", "2"), + ("estimate", "work/a", "One", "--value", "8"), ): with self.subTest(args=args): self.assertEqual(self._run(*args).returncode, 2) @@ -211,7 +213,29 @@ class TestRasci(unittest.TestCase): 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 |")) + self.assertTrue(self._out("show")[2].startswith("| 1 | - | work/a: a \\| b | - | queued |")) + + def test_wsjf_ranks_by_median_estimates(self): + self._run("set", "work/a", "--objective", "Ship A") + self._run("kr", "work/a", "add", "released") + self._run("estimate", "work/a", "One", "--value", "8", "--complexity", "2") + self._run("estimate", "work/a", "Two", "--value", "13", "--complexity", "3") + self._run("estimate", "work/b", "One", "--value", "5", "--complexity", "5") + self._run("set", "work/c", "--title", "Gamma") + lines = self._out("wsjf") + self.assertEqual( + lines[0], "| Rank | Task | OKR | Business value | Complexity | WSJF | Estimators | Status |" + ) + self.assertEqual(lines[2], "| 1 | work/a | Ship A (0/1 KR) | 10.5 | 2.5 | 4.2 | One, Two | queued |") + self.assertEqual(lines[3], "| 2 | work/b | - | 5 | 5 | 1 | One | queued |") + self.assertEqual(lines[4], "| 3 | work/c | - | - | - | - | - | queued |") + self.assertEqual(lines[5], "unestimated: work/c") + self.assertTrue(self._out("show")[2].startswith("| 1 | 4.2 | work/a |")) + self.assertIn("wsjf: 4.2", self._out("get", "work/a")) + self._run("set", "work/c", "--prio", "1") + self.assertTrue(self._out("show")[2].startswith("| 1 | - | work/c: Gamma |")) + self._run("estimate", "work/b", "One", "--drop") + self.assertFalse(any(line.startswith("estimate ") for line in self._out("get", "work/b"))) class TestOrgSkillsUseTheRegister(unittest.TestCase):