Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .council import agent
|
|
from .register import ENDED, status_of
|
|
from .skill_scripts import SAFE_PATH
|
|
|
|
TREE_TTL = 60.0
|
|
TREE_DEPTH = 6
|
|
GIT_TIMEOUT = 10
|
|
|
|
|
|
def git(cwd: Path, args: list[str], home: Path) -> str | None:
|
|
env = {"PATH": SAFE_PATH, "HOME": str(home), "GIT_CONFIG_NOSYSTEM": "1", "GIT_NO_LAZY_FETCH": "1", "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"}
|
|
try:
|
|
done = subprocess.run(
|
|
["git", "-c", "safe.directory=*", "-c", "core.fsmonitor=false", "-C", str(cwd), *args],
|
|
capture_output=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=GIT_TIMEOUT,
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
return done.stdout if done.returncode == 0 else None
|
|
|
|
|
|
def parse_worktrees(text: str) -> list[dict]:
|
|
found = []
|
|
for block in text.strip().split("\n\n"):
|
|
lines = dict(line.partition(" ")[::2] for line in block.splitlines() if line)
|
|
if "worktree" not in lines or "bare" in lines:
|
|
continue
|
|
found.append({
|
|
"path": lines["worktree"], "head": lines.get("HEAD", ""), "branch": lines.get("branch", "").removeprefix("refs/heads/"),
|
|
"prunable": "prunable" in lines,
|
|
})
|
|
return found
|
|
|
|
|
|
def tracking(text: str) -> tuple[int, int, bool]:
|
|
counts = {name: int(number) for name, number in re.findall(r"(ahead|behind) ([0-9]+)", text)}
|
|
return counts.get("ahead", 0), counts.get("behind", 0), text.strip() == "gone"
|
|
|
|
|
|
def parse_refs(text: str) -> list[dict]:
|
|
found = []
|
|
for line in text.splitlines():
|
|
parts = line.split("\x00")
|
|
if len(parts) != 4:
|
|
continue
|
|
ahead, behind, gone = tracking(parts[3])
|
|
found.append({"name": parts[0], "head": parts[1], "upstream": parts[2], "ahead": ahead, "behind": behind, "gone": gone})
|
|
return found
|
|
|
|
|
|
def linked(root: Path) -> list[Path]:
|
|
found = []
|
|
for top, dirs, names in os.walk(root):
|
|
here = Path(top)
|
|
if ".git" in names or ".git" in dirs:
|
|
found.append(here)
|
|
dirs[:] = []
|
|
elif len(here.relative_to(root).parts) >= TREE_DEPTH:
|
|
dirs[:] = []
|
|
else:
|
|
dirs.sort()
|
|
return found
|
|
|
|
|
|
def dirty(path: Path, home: Path) -> bool | None:
|
|
# agents write these repos, and status runs every filter.*.clean command the config names
|
|
if git(path, ["config", "--get-regexp", r"^filter\."], home) is not None:
|
|
return None
|
|
status = git(path, ["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=all"], home)
|
|
return None if status is None else bool(status.strip())
|
|
|
|
|
|
def repo_entry(path: Path, label: str, home: Path) -> dict:
|
|
trees = parse_worktrees(git(path, ["worktree", "list", "--porcelain"], home) or "")
|
|
refs = git(path, ["for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)", "refs/heads"], home)
|
|
branches = parse_refs(refs or "")
|
|
for tree in trees:
|
|
tree["dirty"] = None if tree["prunable"] else dirty(Path(tree["path"]), home)
|
|
known = {branch["name"] for branch in branches}
|
|
branches += [{"name": tree["branch"], "head": tree["head"], "upstream": "", "ahead": 0, "behind": 0, "gone": False} for tree in trees if tree["branch"] and tree["branch"] not in known]
|
|
for branch in branches:
|
|
branch["worktrees"] = [tree for tree in trees if tree["branch"] == branch["name"]]
|
|
return {"path": str(path), "name": label, "branches": branches, "detached": [tree for tree in trees if not tree["branch"]]}
|
|
|
|
|
|
def scan_tree(repos: Path, worktrees: Path, home: Path) -> dict:
|
|
root = repos.resolve()
|
|
try:
|
|
mains = sorted(path.resolve() for path in root.glob("*/*/*") if (path / ".git").is_dir())
|
|
except OSError:
|
|
mains = []
|
|
entries = {str(path): repo_entry(path, str(path.relative_to(root)), home) for path in mains if path.is_relative_to(root)}
|
|
seen = {tree["path"] for entry in entries.values() for branch in entry["branches"] for tree in branch["worktrees"]}
|
|
seen |= {tree["path"] for entry in entries.values() for tree in entry["detached"]}
|
|
for path in linked(worktrees.resolve()):
|
|
if str(path) in seen:
|
|
continue
|
|
common = git(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"], home)
|
|
main = Path(common.strip()).parent if common else None
|
|
if main is not None and str(main) not in entries and (main / ".git").is_dir():
|
|
entries[str(main)] = repo_entry(main, str(main), home)
|
|
return {"repos": sorted(entries.values(), key=lambda entry: entry["name"]), "scanned": time.time()}
|
|
|
|
|
|
def inside(path: str, folder: str) -> bool:
|
|
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
|
|
|
|
|
|
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
|
|
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
|
|
holders: dict[str, set[str]] = {}
|
|
for task, row in current.items():
|
|
names = {agent(row.get("worker"))} | {claim["agent"] for claim in claims if claim["office"] == task}
|
|
holders[task] = {name for name in names if name}
|
|
latest: dict[str, dict] = {}
|
|
for review in reviews:
|
|
latest[review["worktree"]] = {"status": review["status"], "gate": review.get("gate", ""), "title": review["title"]}
|
|
repos = []
|
|
for entry in scan["repos"]:
|
|
branches = []
|
|
for branch in entry["branches"]:
|
|
trees = []
|
|
for tree in branch["worktrees"]:
|
|
workers = set()
|
|
for task, row in current.items():
|
|
workdir, name = row.get("workdir"), row.get("branch")
|
|
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
|
|
by_branch = not isinstance(workdir, str) and name == branch["name"]
|
|
if by_dir or by_branch:
|
|
workers |= holders[task]
|
|
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
|
branches.append({**branch, "worktrees": trees})
|
|
repos.append({**entry, "branches": branches})
|
|
return {"repos": repos, "scanned": scan["scanned"]}
|
|
|
|
|
|
@dataclass
|
|
class Tree:
|
|
repos: Path
|
|
worktrees: Path
|
|
home: Path
|
|
lock: threading.Lock
|
|
cached: dict | None
|
|
scanned: float
|
|
|
|
def get(self) -> dict:
|
|
with self.lock:
|
|
if self.cached is None or time.monotonic() - self.scanned >= TREE_TTL:
|
|
self.cached = scan_tree(self.repos, self.worktrees, self.home)
|
|
self.scanned = time.monotonic()
|
|
return self.cached
|