feat(org): rank the register by WSJF from Fibonacci estimates
🧪 Test / 🧪 Lock + lint (push) Waiting to run
🧪 Test / 🧪 Lock + lint (push) Waiting to run
kvb-org-wsjf: the chair asks its subordinates for Fibonacci estimates of business value and complexity, rasci.py estimate records them per party, and WSJF = median value / median complexity ranks the tasks. rasci.py wsjf prints the WSJF table with OKR and the task id; the register's show takes its Prio from that rank (an operator --prio pin first) and adds a WSJF column. Four 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:
1 parent
35dc37641d
commit
5a93b0e5a9
10 files changed
+181
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ rasci.py get <task>
|
||||
rasci.py rm <task>
|
||||
rasci.py show [--all]
|
||||
rasci.py artifacts [--ended] [--task <task>]
|
||||
rasci.py estimate <task> "<party>" --value <fib> --complexity <fib>
|
||||
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`
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 <task> "<party>" --value <fib> --complexity <fib>
|
||||
rasci.py estimate <task> "<party>" --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 <task> --prio <n>`) 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.
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
+30
-6
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user