Files
skills/skills/kvb-org-rasci/scripts/rasci.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 d9eb121d12 feat(orchestrator): S5 Tasks with choices, Gantt, estimates and burn charts
Requirement 001 phase 6, S5:

- Tasks tab: every pending ask, rule request, operator row and choice
  card shows under "waits on Operator"; a choice request goes out on a
  fixed channel button, post and call with an agent multi-select, and a
  click on an option sends a passkey-signed answer the agent checks with
  operator_message. The unsigned /api/decisions/<id>/choose route is
  gone, and a decision, choice answer or choice recipient whose name
  holds "*" is refused, so a signed answer never reaches every agent.
- Gantt view: one bar per task from start to end (running to now), one
  arrow per explicit, branch-base and queue-order edge, hatched forecast
  bars (always, points only, real only) from the median duration or
  points times median hours per point.
- rasci.py set --after/--drop-after stores task dependencies and refuses
  unknown tasks and cycles.
- Estimation toggle (No estimate default, Story points) is stored in the
  hub settings; burndown and burnup per whole register, list filter or
  one agent or line, counting tasks or summing complexity with the
  unestimated tasks as their own line.
- New skill kvb-org-queue-estimate: "Request estimates" (story points
  mode, passkey-signed board action) or the skill sends exactly one
  newsletter listing every unestimated task with its RASCI parties; a
  party name that is no plain name shows as "unnamed party".
- Business value per task or in bulk is stored only with a verified
  passkey assertion over the exact ids and values; WSJF uses it instead
  of the median. The task list sorts by every decision-50 mode both ways.
- e2e: the walk's pending ask lives an hour instead of 60 s, and the
  CDP and BiDi drivers retry removing the browser profile while the
  browser exits (ENOTEMPTY).

Evidence: make test 709 OK, ruff and markdownlint clean
(/tmp/s5-make-test-fix-2.log); e2e 26 OK in Chromium and Firefox
(/tmp/s5-e2e-fix-2.log); MEDUSA 50 files, an independent skeptic
rejected four holes (unsigned choose route, unsigned estimates request,
free-text party names, "*" recipients incl. normalizer bypasses), all
fixed and re-verified, the 8 remaining pattern hits accepted, rescan 0
open.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 88
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:53:55 +00:00

645 lines
24 KiB
Python
Executable File

#!/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. The operator's agent order
(`agents order`) lies beside it in <root>/agent-order.json, apart from
every task row: it breaks ties of equal task priority only.
/tmp/agents-council unless set.
Exit codes:
0 done, 2 usage error.
"""
from __future__ import annotations
import argparse
import contextlib
import datetime
import importlib.util
import json
import os
import re
import statistics
import sys
from collections.abc import Iterator
from pathlib import Path
from types import ModuleType
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
DB = ROOT / "rasci.json"
LOCK = ROOT / "rasci.lock"
AGENT_ORDER = ROOT / "agent-order.json"
FILE_MODE = 0o600
ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts"
COUNCIL = ORGANIZE / "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"
WIDE = ("Prio", "WSJF", "Task", "OKR", "Status", "Worker", "Artifacts", "Start", "End", *ORDER)
COMPACT = ("Prio", "WSJF", "Task", "Status", "Worker", "A")
STATUSES = ("queued", "running", "blocked", "done", "stopped")
ENDED = ("done", "stopped")
FIELDS = (
"title", "objective", "description", "prio", "status", "worker", "start", "end", "workdir",
"branch", "base",
)
TEXT_FIELDS = ("title", "objective", "description", "worker", "workdir", "branch", "base")
LIST_FIELDS = {"tools": "tool", "after": "after"}
ARTIFACT = re.compile(r"^[a-z]+:\S")
FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
OPERATOR = "Operator"
RECORDS = ("hotfix/", "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 script(name: str, path: Path) -> ModuleType:
spec = importlib.util.spec_from_file_location(name, path)
loaded = importlib.util.module_from_spec(spec)
spec.loader.exec_module(loaded)
return loaded
nofollow = script("kvb_rasci_nofollow", ORGANIZE / "nofollow.py")
def load() -> dict[str, dict]:
try:
tasks = json.loads(nofollow.read_text(ROOT, DB))
except FileNotFoundError:
return {}
except (ValueError, RecursionError):
fail(f"{DB} is not valid JSON")
except OSError as error:
fail(f"{DB} is no regular file below {ROOT}: {error.strerror}")
if not isinstance(tasks, dict) or not all(isinstance(entry, dict) for entry in tasks.values()):
fail(f"{DB} is not an object of task objects")
return tasks
@contextlib.contextmanager
def database() -> Iterator[dict[str, dict]]:
with contextlib.ExitStack() as held:
try:
ROOT.mkdir(parents=True, exist_ok=True)
held.enter_context(nofollow.locked(ROOT, LOCK, FILE_MODE, None))
except OSError as error:
fail(f"cannot lock {LOCK}: {error.strerror}")
tasks = load()
yield tasks
nofollow.replace(ROOT, DB, json.dumps(tasks, indent=1, sort_keys=True), FILE_MODE)
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 council_module() -> ModuleType | None:
return script("council", COUNCIL) if COUNCIL.is_file() else None
def office_holders() -> list[str]:
council = council_module()
if council is None:
return []
return [claim["name"] or claim["session"] for _, claim in council.claims()]
def claimed() -> dict[str, dict]:
council = council_module()
if council is None:
return {}
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 wsjf(row: dict) -> tuple[float, float, float] | None:
"""Returns:
Business value, complexity and their quotient: the Operator's
business value where it recorded one, else the median of all, over
the median complexity; None while no estimate carries a complexity.
"""
estimates = row.get("estimates", {})
sized = [e["complexity"] for e in estimates.values() if "complexity" in e]
if not sized:
return None
own = estimates.get(OPERATOR)
value = own["value"] if own else statistics.median(e["value"] for e in estimates.values())
complexity = statistics.median(sized)
return value, complexity, round(value / complexity, 2)
def follows(tasks: dict[str, dict], start: str, target: str) -> bool:
seen: set[str] = set()
todo = list(tasks.get(start, {}).get("after", []))
while todo:
current = todo.pop()
if current == target:
return True
if current not in seen:
seen.add(current)
todo.extend(tasks.get(current, {}).get("after", []))
return False
def check_after(tasks: dict[str, dict], task: str, after: list[str]) -> None:
for other in after:
check_task(other)
if other == task:
fail(f"{task} cannot follow itself")
if other not in tasks:
fail(f"{task}: --after {other} is no task of the register")
def agent_order() -> list[str]:
try:
data = json.loads(nofollow.read_text(ROOT, AGENT_ORDER))
except (OSError, ValueError, RecursionError):
return []
names = data.get("agents") if isinstance(data, dict) else None
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
return []
return names
def ranked(rows: dict[str, dict]) -> list[str]:
order = {name: index for index, name in enumerate(agent_order())}
def key(task: str) -> tuple:
row = rows[task]
score = wsjf(row)
pinned = row.get("prio")
agent = order.get(row.get("worker"), len(order))
return (pinned is None, pinned or 0, score is None, -(score[2] if score else 0), agent, task)
return sorted(rows, key=key)
def by_agent(rows: dict[str, dict]) -> dict[str, list[str]]:
agents: dict[str, list[str]] = {}
for task in ranked(rows):
agents.setdefault(rows[task].get("worker") or "-", []).append(task)
return agents
def agents_ranked(rows: dict[str, dict]) -> dict[str, list[str]]:
tasks = by_agent(rows)
for holder in office_holders():
tasks.setdefault(holder, [])
listed = [name for name in agent_order() if name in tasks]
return {name: tasks[name] for name in [*listed, *(name for name in tasks if name not in listed)]}
def store_order(text: str) -> int:
names = [name.strip() for name in text.split(",") if name.strip()]
known = agents_ranked(merged(include_done=False))
unknown = [name for name in names if name not in known]
if not names or unknown or len(set(names)) != len(names):
fail(f"agents order needs distinct agents from `agents`; unknown: {', '.join(unknown) or '-'}")
try:
ROOT.mkdir(parents=True, exist_ok=True)
with nofollow.locked(ROOT, LOCK, FILE_MODE, None):
nofollow.replace(ROOT, AGENT_ORDER, json.dumps({"agents": names, "set": stamp()}, indent=1), FILE_MODE)
except OSError as error:
fail(f"agents order cannot write {AGENT_ORDER}: {error.strerror or error}")
print(f"agent order: {', '.join(names)}; the others follow by register rank; task --prio pins unchanged")
return 0
def cmd_agents(args: argparse.Namespace) -> int:
if args.action == "order":
return store_order(args.agents)
for rank, (agent, tasks) in enumerate(agents_ranked(merged(include_done=False)).items(), 1):
print(f"{rank}\t{agent}\t{len(tasks)}\t{', '.join(tasks) or '-'}")
return 0
def cmd_order(args: argparse.Namespace) -> int:
print(
"rasci: `order` is now `agents order`: it stores the agent order beside the register "
"and no longer writes --prio pins",
file=sys.stderr,
)
return store_order(args.agents)
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]:
if task.startswith(RECORDS):
return []
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:
check_after(tasks, args.task, args.after)
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)
if args.after and follows(tasks, args.task, args.task):
fail(f"{args.task}: --after {', '.join(args.after)} closes a cycle; drop one with --drop-after")
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 '-'}")
for party, estimate in sorted(row.get("estimates", {}).items()):
print(f"estimate {party}: business value {estimate['value']}, complexity {estimate.get('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)
alone = args.complexity is None and args.party == OPERATOR
if not args.drop:
for name in ("value",) if alone else ("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:
complexity = estimates.get(args.party, {}).get("complexity") if alone else args.complexity
sized = {} if complexity is None else {"complexity": complexity}
estimates[args.party] = {"value": args.value, **sized, "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 = {task: row for task, row in merged(args.all).items() if not task.startswith(RECORDS)}
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
def cmd_artifacts(args: argparse.Namespace) -> int:
rows = merged(True)
for task in sorted(rows):
row = rows[task]
if task.startswith(RECORDS) or (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 holders(row: dict, letter: str, width: int) -> str:
parties = sorted(row["roles"], key=lambda p: (p != OPERATOR, p.lower()))
return cell(", ".join(p for p in parties if letter in row["roles"][p]), width)
def wide(task: str, row: dict) -> list[object]:
title = f"{task}: {row['title']}" if row.get("title") else task
return [
cell(title, 50),
okr(row),
row["status"],
cell(row.get("worker") or "-", 30),
len(row.get("artifacts", [])) or "-",
when(row.get("start")),
when(row.get("end")),
*(holders(row, letter, 30) for letter in ORDER),
]
def compact(task: str, row: dict) -> list[object]:
title = cell(f"{task}: {row['title']}", max(40, len(task) + 1)) if row.get("title") else task
return [title, row["status"], cell(row.get("worker") or "-", 24), holders(row, "A", 24)]
def cmd_show(args: argparse.Namespace) -> int:
rows = merged(args.all)
if not rows:
print("no tasks")
return 0
header, columns = (WIDE, wide) if args.wide else (COMPACT, compact)
print("| " + " | ".join(header) + " |")
print("|" + "---|" * len(header))
problems = []
for rank, task in enumerate(ranked(rows), 1):
row = rows[task]
score = wsjf(row)
values = [rank, f"{score[2]:g}" if score else "-", *columns(task, row)]
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("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"))
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.add_argument("--wide", 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)
p = sub.add_parser("agents")
p.set_defaults(func=cmd_agents)
order = p.add_subparsers(dest="action").add_parser("order")
order.add_argument("agents")
p = sub.add_parser("order")
p.add_argument("agents")
p.set_defaults(func=cmd_order)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())