diff --git a/README.md b/README.md index 98adabb..f686701 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline ## Naming đŸ·ïž -Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `release`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `release` announces a release (changelog, article, posts), `check` analyses and reports findings, `org` coordinates agents, `agent` acts on one agent itself: its repository setup (code index, agent instructions) and its state (relax, sleep, wake up), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category. +Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `release`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `release` announces a release (changelog, article, posts), `check` analyses and reports findings, `org` coordinates agents, `agent` acts on one agent itself: its repository setup (code index, agent instructions) and its state (drain, suspend, resume), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category. ## Organigram đŸ›ïž @@ -82,17 +82,18 @@ flowchart LR org --> org_robot[kvb-org-robot] org --> org_operator[kvb-org-operator] org --> org_priority[kvb-org-priority] - org --> org_evening[kvb-org-evening] - org --> org_sunset[kvb-org-sunset] - org --> org_sunrise[kvb-org-sunrise] + org --> org_drain[kvb-org-drain] + org --> org_suspend[kvb-org-suspend] + org --> org_resume[kvb-org-resume] think --> think_choice[kvb-think-choice] org --> org_radio[kvb-org-radio] org --> org_queue[kvb-org-queue] + org --> org_queue_estimate[kvb-org-queue-estimate] org --> org_update[kvb-org-update] agent --> agent_optimize[kvb-agent-optimize] - agent --> agent_relax[kvb-agent-relax] - agent --> agent_sleep[kvb-agent-sleep] - agent --> agent_wakeup[kvb-agent-wakeup] + agent --> agent_drain[kvb-agent-drain] + agent --> agent_suspend[kvb-agent-suspend] + agent --> agent_resume[kvb-agent-resume] skill --> skill_tune[kvb-skill-tune] skill --> skill_auto[kvb-skill-auto] skill --> skill_essence[kvb-skill-essence] @@ -190,18 +191,19 @@ flowchart LR robot[kvb-org-robot] operator[kvb-org-operator] priority[kvb-org-priority] - evening[kvb-org-evening] - sunset[kvb-org-sunset] - sunrise[kvb-org-sunrise] + org_drain[kvb-org-drain] + org_suspend[kvb-org-suspend] + org_resume[kvb-org-resume] radio[kvb-org-radio] queue[kvb-org-queue] + queue_estimate[kvb-org-queue-estimate] org_update[kvb-org-update] end subgraph cat_agent [agent] agent_optimize[kvb-agent-optimize] - relax[kvb-agent-relax] - sleep[kvb-agent-sleep] - wakeup[kvb-agent-wakeup] + agent_drain[kvb-agent-drain] + agent_suspend[kvb-agent-suspend] + agent_resume[kvb-agent-resume] end subgraph cat_skill [skill] autotune[kvb-skill-tune] @@ -346,13 +348,13 @@ flowchart LR optimize --> gaps agent_optimize -. install .-> operator agent_optimize -. domain graphs .-> rasci - evening -. message .-> relax - sunset -. message .-> sleep - sunrise -. message .-> wakeup - relax --> sleep - sunset -. hold and accept .-> operator - sunrise -. hold and accept .-> operator - sleep -. stops .-> robot + org_drain -. message .-> agent_drain + org_suspend -. message .-> agent_suspend + org_resume -. message .-> agent_resume + agent_drain --> agent_suspend + org_suspend -. hold and accept .-> operator + org_resume -. hold and accept .-> operator + agent_suspend -. stops .-> robot coach --> active_listening coach --> operator coach --> rasci @@ -406,17 +408,17 @@ Both commands also patch the target's `.claude/settings.local.json` — the mach The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT, 82 MB). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`. Its Tree reads only the file `pkgmgr branch tree` writes: the install clones `PKGMGR_REPO` (default the GitHub repository, never the sandbox-writable local checkout), builds the newest `v*` tag a fully trusted GPG key signed into `~/.local/share/kvb-orchestrator/pkgmgr`, and enables the `kvb-orchestrator-tree.timer` user timer, which writes `~/.local/state/pkgmgr/tree/branch-tree.json` a minute after its previous run while the hub page polls, on at most half a core; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off. -Stream → Radio (`#stream/radio`) is the hub's radio interface. Its station list shows one line per station, the full name in the tooltip, with its state and a play or stop button; when the browser blocks autoplay, the stream fails or the CSP refuses its origin, the row and a toast say so, and one click starts the station. New stations play daily around the clock. While the hub reads a message aloud, the speaking agent pulses there and in the Orbit and Chord views, under the caption " spricht" with the message's first line; under `prefers-reduced-motion` the ring stays still. The composer sends on all seven channels: post and call to one agent, newsletter, broadcast, pan-pan, security and mayday to all. "Aufnehmen" records with MediaRecorder; the hub transcribes the recording with whisper.cpp off the request thread (at most 120 s of audio, a 120 s timeout, four at a time), keeps it for replay in `~/.local/state/kvb-orchestrator/voice` (the newest 50) and puts the transcript into the field to edit; the message goes out as text, with the recording's host path and sha256 only when "Aufnahme anhĂ€ngen" is ticked. The hub transcribes at most six recordings a minute and 600 s of audio an hour. Every operator message is passkey-signed, and the hub verifies the signature against the pinned keys, each once, before it relays; `operator_message(id, text, to)` answers `match` only for the signed text and recipient; for a denial the signature covers the recipient the inbox resolved at signing, and the answer's text is only the signed reason, never the agent's own words the radio row quotes. "Mayday aufheben", Abend, Sonnenuntergang, Sonnenaufgang and a settings save that adds a stream URL take the passkey the same way. +Stream → Radio (`#stream/radio`) is the hub's radio interface. Its station list shows one line per station, the full name in the tooltip, with its state and a play or stop button; when the browser blocks autoplay, the stream fails or the CSP refuses its origin, the row and a toast say so, and one click starts the station. New stations play daily around the clock. While the hub reads a message aloud, the speaking agent pulses there and in the Orbit and Chord views, under the caption " speaking" with the message's first line; under `prefers-reduced-motion` the ring stays still. The composer sends on all seven channels: post and call to one agent, newsletter, broadcast, pan-pan, security and mayday to all. "Record" records with MediaRecorder; the hub transcribes the recording with whisper.cpp off the request thread (at most 120 s of audio, a 120 s timeout, four at a time), keeps it for replay in `~/.local/state/kvb-orchestrator/voice` (the newest 50) and puts the transcript into the field to edit; the message goes out as text, with the recording's host path and sha256 only when "Attach recording" is ticked. The hub transcribes at most six recordings a minute and 600 s of audio an hour. Every operator message is passkey-signed, and the hub verifies the signature against the pinned keys, each once, before it relays; `operator_message(id, text, to)` answers `match` only for the signed text and recipient; for a denial the signature covers the recipient the inbox resolved at signing, and the answer's text is only the signed reason, never the agent's own words the radio row quotes. "Cancel mayday", Evening, Sunset, Sunrise and a settings save that adds a stream URL take the passkey the same way. -The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Löschen" on a `merged-clean` branch without skip reason, or "Alle gemergten löschen", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch ` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`. +The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Delete" on a `merged-clean` branch without skip reason, or "Delete all merged", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch ` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`. -The Agents view "Hierarchie" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/`) and presidents (`work/`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim ` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin. +The Agents view "Hierarchy" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/`) and presidents (`work/`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim ` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin. -The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "wartet auf Freigabe", and the filter "Wartet auf Operator" is the inbox, which the Inbox entry under Stream opens. A task's sheet pins its priority (`rasci.py set --prio `) or sets and drops the operator's WSJF estimate (`rasci.py estimate Operator --value --complexity ` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci --council `, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar. +The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "waiting for a grant", and the filter "Waits on Operator" is the inbox, which the Inbox entry under Stream opens: every pending review, ask, rule request, operator row and choice card, with no Choices column of its own. "Request choices" has one button per channel, post and call to the agents ticked under "Agents", newsletter, broadcast, pan-pan, SĂ©curitĂ© and mayday to all, that sends the fixed request "send me your open decisions as choices" without a text field, one passkey assertion over every recipient (`/api/choices/sign`, then `/api/choices`). Agents answer with `decision_request`, shown as choice cards, and a click on an option is the passkey-signed answer: after the 3 s undo the hub verifies the signature and posts the answer to the asking agent, whose `decision_wait` returns it as `message` and `answer` for `operator_message`. A task's sheet pins its priority (`rasci.py set --prio `) or sets and drops the operator's WSJF estimate (`rasci.py estimate Operator --value --complexity ` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci --council `, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar. -Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage). +Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=` pins the passkey that "Register passkey" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage). -For approvals the hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port --approvers ~/.config/kvb-orchestrator/approvers.json --credential ''`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. In the inbox, an operator command run with "AusfĂŒhren" shows the runner's output in a console under its row until "Schließen". "Ablehnen" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Annehmen" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port --timeout --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and review rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (decision choices, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks//deny`, console dismissals) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap. +For approvals the hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Register passkey" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port --approvers ~/.config/kvb-orchestrator/approvers.json --credential ''`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. In the inbox, an operator command run with "Run" shows the runner's output in a console under its row until "Close". "Deny" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Accept" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port --timeout --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; review rejections and a decision a rule takes stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (the bare decision choice `/api/decisions//choose`, which sends no answer, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks//deny`, console dismissals) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap. Restart your agent afterwards so it loads the new skills. diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index cbbd626..d7a0c3a 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -47,27 +47,27 @@ from three views down to any agent, branch or artefact. (directory, module, container, systemd unit, MCP server, environment `KVB_ORCHESTRATOR_*`, make targets). Rejected: visible name only (two names for one thing). -10. **Navigation**: Stream (sub-items Inbox and Regeln) · Agents (ex Graph) · +10. **Navigation**: Stream (sub-items Inbox and Firewall) · Agents (ex Graph) · Artifacts (ex Tree). Agents and Artifacts each offer Orbit, Chord, Swimlane, Tree and 3D. 11. **Header**: title · ⌘K · ⚙ only. The status pills become navigation badges (Inbox: waiting items, Agents: active agents, Artifacts: runner - jobs); Mayday shows only as a red banner with "aufheben" while active; the + jobs); Mayday shows only as a red banner with "cancel" while active; the background shows day and night. Rejected: one status dot with a popover - (an extra click), a status block in a side bar, a "Lage" screen (effort), - no header at all. -12. **Tools**: Steuerung (Funk an alle, Mayday aufheben, PrioritĂ€t, Tag/Nacht) - moves to the Agents toolbar, Regeln under Inbox, Himmel, Musik, - Leitstand and Benachrichtigungen to ⚙ "Ambiente"; ⌘K reaches every one. + (an extra click), a status block in a side bar, a "Situation" screen + (effort), no header at all. +12. **Tools**: Controls (Radio to all, Cancel mayday, Priority, Day/Night) + moves to the Agents toolbar, Firewall under Inbox, Sky, Music, + Control room and Notifications to ⚙ "Ambience"; ⌘K reaches every one. Rejected: one ⋯ menu (hides instead of ordering). -13. **Stream**: four category chips FĂŒr dich · Funk · Arbeit · Transkripte; - one search line with `agent:`, `art:`, `seit:`; sub-filters, saved views +13. **Stream**: four category chips For you · Radio · Work · Transcripts; + one search line with `agent:`, `kind:`, `since:`; sub-filters, saved views and a custom range under "Filter â–Ÿ"; the time bar becomes "24 h â–Ÿ" with the replay inside; transcripts are off by default. Rejected: a self-grouping stream, a Kibana-like query language (effort), preset chips only. 14. **3D**: `3d-force-graph` 1.80.1 on `three` 0.186.1, vendored as in - meta.infinito.nexus; the Canvas depth axes Zeit and Spawn-Tiefe become 3D + meta.infinito.nexus; the Canvas depth axes Time and Spawn depth become 3D options. Rejected: keeping the Orbit-only Canvas depth. 15. **Artifacts scope**: git (repositories → branches → worktrees with their agents and reviews) plus the kvb-org-rasci register artefacts (images, @@ -99,8 +99,7 @@ from three views down to any agent, branch or artefact. locked, stale, protected and the gc skip reason. The Artifacts Tree shows it as icons. Phase 3 builds the data, phase 4 the icons. 21. **Delete from the tree**: merged, orphaned branches are deleted by click - in the Artifacts Tree (phase 4), per item and as "Alle gemergten - löschen": + in the Artifacts Tree (phase 4), per item and as "Delete all merged": - **Credentials**: only while the Secret Service hands pkgmgr the GitHub token, which proves the merge and the missing open pull request; no secret passes the orchestrator container. Rejected: the master password @@ -115,7 +114,7 @@ from three views down to any agent, branch or artefact. `pkgmgr branch gc --apply` deletes today (remote branch and `compose down -v`). - **Confirmation**: the click shows the gc dry-run lines of the chosen - items, and "Löschen" needs the operator signature (`/api/sign`). + items, and "Delete" needs the operator signature (`/api/sign`). Rejected: a second click without signature, one click without preview. - **Execution**: the orchestrator writes a signed job into a host directory; a systemd user path unit runs `pkgmgr branch cleanup --apply @@ -125,19 +124,19 @@ from three views down to any agent, branch or artefact. and client which skills changed and how to use them, as one `newsletter` per update; phase 1 announces the radio change through it. 23. **Phase 5** (23 to 43 operator-confirmed 2026-11-07): the console - output shows under "AusfĂŒhren" (Run); a deny picks its channel (default + output shows under "Run"; a deny picks its channel (default `post`); agents request auto-approval rules through the - inbox; the rules form and the Ort fields never overlap; the radio plays + inbox; the rules form and the Location fields never overlap; the radio plays with one line per station and highlights the speaking agent; the operator composer sends text or voice, recorded, transcribed locally with whisper in the container and kept as audio, to a recipient by `post` or - `call`; a Hierarchie view shows the council offices with appoint and + `call`; a Hierarchy view shows the council offices with appoint and recall; the agent order (drag list) is separate from task priority (WSJF plus the operator's pin) and only breaks ties; one Tasks tab holds list, board and RASCI matrix, the inbox as its "waits on Operator" filter and the queue state; the merge flow, a pkgmgr-managed YAML per worktree with several pull sources and push targets, shows in Artifacts and Tasks. -24. **Firewall**: the tab Regeln becomes Firewall and holds every form of +24. **Firewall**: the rules tab becomes Firewall and holds every form of allow-listing: the hub's auto-approval rules (ask, review, decision), allow and deny command patterns, and the Claude settings permissions (`allow`, `ask`, `deny`) and sandbox entries. Rejected: a separate @@ -168,7 +167,7 @@ from three views down to any agent, branch or artefact. `kvb-org-evening` → `kvb-org-drain`, `kvb-org-sunset` → `kvb-org-suspend`, `kvb-org-sunrise` → `kvb-org-resume`, `kvb-agent-relax` → `kvb-agent-drain`, `kvb-agent-sleep` → `kvb-agent-suspend`, - `kvb-agent-wakeup` → `kvb-agent-resume`; `night.py` and the Steuerung + `kvb-agent-wakeup` → `kvb-agent-resume`; `night.py` and the Controls menu follow, `kvb-skill-cleanup` removes the old names. Rejected: sleep / hibernate / wakeup, pause / park / resume. 30. **Agent popup**: a click on an agent opens a fading popup with a simple, @@ -196,7 +195,7 @@ from three views down to any agent, branch or artefact. 36. **Tree cause**: the tree timer builds pkgmgr from the newest signed tag, and v2.3.0 has no `branch tree`, so no tree file exists. The Tree names that cause and the operator step (the pkgmgr release) instead of a bare - "Kein Tree"; with pkgmgr 2.4.0 released the timer writes the file. + "No tree"; with pkgmgr 2.4.0 released the timer writes the file. 37. **Resources**: a tab Resources shows charts on the shared time axis: tokens per agent and accumulated (from the usage fields of the session transcripts), and CPU, RAM, disk and load of the host, sampled by a host @@ -324,7 +323,7 @@ Phases 5 and 6 (decisions 23 to 42): ```mermaid flowchart TD - subgraph W["đŸ§© 5 · Inbox, UI, Hierarchie, Tasks"] + subgraph W["đŸ§© 5 · Inbox, UI, Hierarchy, Tasks"] W1["đŸ› ïž W1 to W4 in own worktrees"] --> W2["đŸ•”ïž Skeptic round, fix round per worktree"] W2 --> W3{"đŸ§Ș Suite, e2e, MEDUSA, score green?"} W3 -- "🔮 no: fix" --> W2 @@ -422,7 +421,7 @@ flowchart TD `kvb-orchestrator` names. A second run changes nothing (scratch-HOME test). - [ ] The navigation has exactly three top-level entries Stream, Agents and - Artifacts; Stream has the sub-items Inbox and Regeln. + Artifacts; Stream has the sub-items Inbox and Firewall. - [ ] Every agent appears once under its current name: a register worker such as "Stage (this session)" or "infinito-nexus-core-c8 (Production)" maps to the agent named before the bracket. @@ -438,18 +437,18 @@ flowchart TD - [ ] Agents shows the agents with a heartbeat in the last 900 s. - [ ] Artifacts shows the runner jobs that are queued or running. - [ ] A badge at 0 is hidden. -- [ ] A red Mayday banner with "aufheben" exists if and only if a mayday is - active; "aufheben" runs `radio.py clear`. +- [ ] A red Mayday banner with "cancel" exists if and only if a mayday is + active; "cancel" runs `radio.py clear`. - [ ] The Agents toolbar holds: - - [ ] Funk an alle, with the class choice Newsletter, Broadcast, Pan-Pan, + - [ ] Radio to all, with the class choice Newsletter, Broadcast, Pan-Pan, Security, Mayday and Newsletter preselected. - - [ ] Mayday aufheben, PrioritĂ€t and Tag/Nacht. + - [ ] Cancel mayday, Priority and Day/Night. - [ ] The agent panel sends with the choice Post (preselected) or Call. -- [ ] ⚙ "Ambiente" holds Himmel, Musik, Leitstand and Benachrichtigungen; +- [ ] ⚙ "Ambience" holds Sky, Music, Control room and Notifications; ⌘K lists every former header tool. -- [ ] The Stream shows exactly four category chips FĂŒr dich, Funk, Arbeit and - Transkripte, and one search line that filters by `agent:`, `art:` and - `seit:`. +- [ ] The Stream shows exactly four category chips For you, Radio, Work and + Transcripts, and one search line that filters by `agent:`, `kind:` and + `since:`. - [ ] "Filter â–Ÿ" holds the kind sub-filters, the transcript parts, the saved views and the custom range. - [ ] The time control reads "24 h â–Ÿ" and holds the replay. @@ -515,7 +514,7 @@ flowchart TD - [ ] 3D in both tabs runs on the vendored `3d-force-graph` 1.80.1 and `three` 0.186.1, with their MIT licence texts beside them and no CDN request (CSP unchanged). -- [ ] 3D offers the layouts Kraft, Zeit and Spawn-Tiefe. +- [ ] 3D offers the layouts Force, Time and Spawn depth. - [ ] A click on a 3D node opens the same panel as in 2D. - [ ] 3D respects `prefers-reduced-motion`. @@ -525,10 +524,10 @@ flowchart TD merged (with what merged it), clean, uncommitted changes (with counts), unpushed commits, gone upstream, locked, stale and protected. - [ ] A `merged-clean` item without a gc skip reason shows a delete button, - and the Tree header shows "Alle gemergten löschen"; both are disabled, + and the Tree header shows "Delete all merged"; both are disabled, with the reason as tooltip, while `delete_ready` is false. - [ ] A click shows the gc dry-run lines of exactly the chosen items; - "Löschen" requires the operator signature through `/api/sign`. + "Delete" requires the operator signature through `/api/sign`. - [ ] The orchestrator writes the signed job (repository, branch, worktree and tip sha per item, no command) into a host-mounted job directory and holds no git, token or key. @@ -555,7 +554,7 @@ flowchart TD - [ ] Every icon element has a `title` and an `aria-label` that carry the former text (DOM test over every page). -### Phase 5: inbox, UI, Hierarchie, Tasks +### Phase 5: inbox, UI, Hierarchy, Tasks - [ ] A deny is refused by the hub unless its passkey proof verifies; the PermissionRequest hook quotes a deny reason only when it verifies. @@ -570,61 +569,61 @@ flowchart TD ### Phase 6: English and radio speech (S0) -- [ ] The text handed to the speech engine for a `security` row starts with +- [x] The text handed to the speech engine for a `security` row starts with "say-cure-ee-tay" three times; the page shows "SĂ©curitĂ©". -- [ ] `8e180be` is spoken "eight echo one eight zero bravo echo" and +- [x] `8e180be` is spoken "eight echo one eight zero bravo echo" and `42305` "four two tree zero fife" (unit test on the pronunciation step); the displayed line is unchanged. -- [ ] A scan of the tree finds no German UI, message or document text +- [x] A scan of the tree finds no German UI, message or document text outside quoted trigger phrases (test with an allowlist of those phrases). ### Phase 6: fixes (S1) -- [ ] `#agents/orbit` at 1920, 1366 and 390 px has +- [x] `#agents/orbit` at 1920, 1366 and 390 px has `document.documentElement.scrollHeight <= innerHeight` (e2e probe). -- [ ] Two state builds with the same claims add no second rename event to +- [x] Two state builds with the same claims add no second rename event to the Stream (unit test on the timeline). -- [ ] The Deutschlandfunk AAC stream plays in Chromium and Firefox through +- [x] The Deutschlandfunk AAC stream plays in Chromium and Firefox through the radio (e2e with a recorded fixture of its redirect chain) and the root cause is named in the commit message. -- [ ] With squelch at 0 no beep plays, music on or off (unit test on the +- [x] With squelch at 0 no beep plays, music on or off (unit test on the audio scheduler). -- [ ] Without a tree file the Tree names the missing `pkgmgr branch tree` +- [x] Without a tree file the Tree names the missing `pkgmgr branch tree` and the release step. ### Phase 6: popups and search (S2) -- [ ] A click on an agent opens the popup with a fade; Escape and a click +- [x] A click on an agent opens the popup with a fade; Escape and a click outside close it; the Details side panel no longer exists. -- [ ] A click on a node enlarges it, marks it red and draws a leader line to +- [x] A click on a node enlarges it, marks it red and draws a leader line to a translucent popup; a second click elsewhere restores the node. -- [ ] A search field with suggestions replaces the node chip list; no view +- [x] A search field with suggestions replaces the node chip list; no view renders more than one chip per search hit. ### Phase 6: faces (S3) -- [ ] The face generator yields at least 1000 distinct attribute +- [x] The face generator yields at least 1000 distinct attribute combinations and the same face for the same agent id (unit test). -- [ ] A speaking agent's face animates its mouth; a silent one does not +- [x] A speaking agent's face animates its mouth; a silent one does not (DOM test on the class while the radio plays). ### Phase 6: night commands (S4) -- [ ] `kvb-org-drain`, `kvb-org-suspend`, `kvb-org-resume`, +- [x] `kvb-org-drain`, `kvb-org-suspend`, `kvb-org-resume`, `kvb-agent-drain`, `kvb-agent-suspend` and `kvb-agent-resume` exist; the old six names exist nowhere in the tree except in the migration and `kvb-skill-cleanup` history (grep test). -- [ ] The Steuerung menu shows Drain, Suspend and Resume and each needs the +- [x] The Controls menu shows Drain, Suspend and Resume and each needs the passkey, like the board actions today. ### Phase 6: Tasks with asks and choices (S5) -- [ ] Every pending ask, rule request, operator row and choice card shows in +- [x] Every pending ask, rule request, operator row and choice card shows in the Tasks tab under "waits on Operator"; no Choices column exists. -- [ ] Each channel button sends the fixed choice request with no text field; +- [x] Each channel button sends the fixed choice request with no text field; `post` and `call` take a multi-select of agents. -- [ ] A click on a choice option sends a passkey-signed answer the agent can +- [x] A click on a choice option sends a passkey-signed answer the agent can check with `operator_message`. ### Phase 6: Firewall (S6) @@ -657,31 +656,31 @@ flowchart TD ### Phase 6: Gantt, estimates, burndown and burnup (S5) -- [ ] `rasci.py set --after ` stores the dependency and +- [x] `rasci.py set --after ` stores the dependency and `--drop-after ` removes it; an unknown task and a cycle are refused. -- [ ] The Gantt view draws one bar per task from start to end, a running +- [x] The Gantt view draws one bar per task from start to end, a running task to now, and one arrow per explicit, branch-base and queue-order edge of a fixture register and queue. -- [ ] The forecast selector offers `always` (default), `points only` and +- [x] The forecast selector offers `always` (default), `points only` and `real only`; a hatched bar is as long as the median duration of done tasks (no estimate) or points times median hours per point (story points). -- [ ] The estimation toggle defaults to `No estimate`, is stored in the hub +- [x] The estimation toggle defaults to `No estimate`, is stored in the hub settings and survives a reload. -- [ ] Burndown and burnup offer the scopes whole register (default), list +- [x] Burndown and burnup offer the scopes whole register (default), list filter within the time window, and one agent or line; with no estimate they count tasks, with story points they sum complexity and draw the unestimated tasks as a line of their own. -- [ ] "Request estimates" shows only in story points mode; a click or +- [x] "Request estimates" shows only in story points mode; a click or `/kvb-org-queue-estimate` sends exactly one newsletter to all agents that lists every unestimated task with every party holding a RASCI role in it; a state build or a mode switch sends none. -- [ ] A per-task or bulk business value change is stored only with a +- [x] A per-task or bulk business value change is stored only with a verified passkey assertion over the exact task ids and values; a forged, replayed or altered batch changes nothing, and WSJF uses the operator value instead of the median. -- [ ] The task list sorts by each mode of decision 50 in both directions. +- [x] The task list sorts by each mode of decision 50 in both directions. ### Tests @@ -701,7 +700,7 @@ flowchart TD rename-once timeline, squelch scheduler, face generator, settings writer (forged, replayed, unlisted file), token sums, German text scan. - - [ ] S5 Gantt and estimates: dependency edges and cycle refusal, forecast lengths, burn + - [x] S5 Gantt and estimates: dependency edges and cycle refusal, forecast lengths, burn sums per mode and scope, newsletter body and its single send, bulk business value signature (forged, replayed, altered), sort orders. - **Integration** @@ -716,7 +715,7 @@ flowchart TD them. - [ ] `make test` green on the commit of each phase. - **E2E** - - [ ] A headless Chromium and Firefox run opens Stream, Inbox, Regeln and + - [ ] A headless Chromium and Firefox run opens Stream, Inbox, Firewall and every view of Agents and Artifacts at 1920, 1366 and 390 px wide and saves the screenshots shown at each operator checkpoint. - [ ] It sends a Call from the agent panel and sees its receipt in the @@ -726,6 +725,6 @@ flowchart TD - [ ] Phase 6: no page scrollbar on any view, agent and node popups, choice request and signed answer, Firewall settings diff and signature, Resources charts, each at 1920, 1366 and 390 px. - - [ ] S5: Gantt with arrows, burndown and burnup, the estimation toggle, + - [x] S5: Gantt with arrows, burndown and burnup, the estimation toggle, "Request estimates" and a signed bulk business value change, each at 1920, 1366 and 390 px. diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index f4d2869..5579537 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -30,13 +30,13 @@ COMMANDS = "operator/" APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"} RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"} WARNINGS = ( - ("löscht rekursiv", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")), - ("erzwingt", re.compile(r"--force\b|\bpush\b.*\s-f\b")), - ("pusht", re.compile(r"\bgit\b.*\bpush\b")), + ("deletes recursively", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")), + ("forces", re.compile(r"--force\b|\bpush\b.*\s-f\b")), + ("pushes", re.compile(r"\bgit\b.*\bpush\b")), ("root", re.compile(r"\bsudo\b|\bdoas\b")), - ("Pipe in eine Shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")), - ("verwirft Änderungen", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")), - ("Docker löscht", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")), + ("pipes into a shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")), + ("discards changes", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")), + ("Docker deletes", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")), ) @@ -152,8 +152,9 @@ def task_argv(task: object, argv: object) -> list[str]: task: the register task the change is for, a rasci.py slug outside operator/. argv: the rasci.py arguments, one of `set --prio <1-999>`, - `estimate Operator --value --complexity ` or - `estimate Operator --drop`. + `estimate Operator --value --complexity `, + `estimate Operator --value ` (the operator's business + value alone) or `estimate Operator --drop`. Returns: argv, the hub and the runner check it the same way. @@ -167,7 +168,8 @@ def task_argv(task: object, argv: object) -> list[str]: raise Refused("the argv is no list of texts") rest = argv[3:] pinned = argv[:3] == ["set", task, "--prio"] and len(argv) == 4 and bool(PRIO.fullmatch(argv[3])) - estimated = len(rest) == 4 and rest[0] == "--value" and rest[1] in FIBONACCI and rest[2] == "--complexity" and rest[3] in FIBONACCI + valued = len(rest) in (2, 4) and rest[0] == "--value" and rest[1] in FIBONACCI + estimated = valued and (len(rest) == 2 or (rest[2] == "--complexity" and rest[3] in FIBONACCI)) if pinned or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])): return argv raise Refused("the argv is no task priority or WSJF estimate change of this task") diff --git a/orchestrator/kvb_orchestrator/approving.py b/orchestrator/kvb_orchestrator/approving.py index 801617a..64dc5ca 100644 --- a/orchestrator/kvb_orchestrator/approving.py +++ b/orchestrator/kvb_orchestrator/approving.py @@ -4,6 +4,7 @@ import secrets import time from .asking import ask_state +from .choosing import answer_signed from .cleanup import cleanup_request from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words from .errors import Conflict, Missing, ToolError @@ -35,7 +36,8 @@ def inbox_item(raw: dict, label: str, title: str, gate: str, subject: dict | Non label: the chosen option of a decision, else empty. title: what the stream logs for the item. gate: the review gate, else empty. - subject: what an approval signs; None for a decision. + subject: what an approval signs; for a decision the answer post to + the asking agent. requester: the requesting agent's name for a denial, else empty. Returns: @@ -81,7 +83,7 @@ class Approving: try: verifier.check(proof, item, "", trust, time.time()) except signing.Refused as error: - raise ToolError(f"nicht signiert: {error}") from None + raise ToolError(f"not signed: {error}") from None return proof def still_signed(self, proof: dict, item: dict) -> None: @@ -94,7 +96,7 @@ class Approving: raise signing.Refused("the signature does not list this item") passkey.verify_assertion(proof["assertion"], proof["payload"], verifier.pinned(self.approvers), signing.origin_of(self.public_port)) except signing.Refused as error: - raise ToolError(f"nicht signiert: {error}") from None + raise ToolError(f"not signed: {error}") from None def covered(self, proof: object, signed: list[dict], now: float) -> dict | None: """Args: @@ -105,14 +107,15 @@ class Approving: Returns: The proof to relay, None when nothing needs a signature. The hub verifies the signature of a denial itself, since it acts on it; - of an approval it checks the shape, and the executors verify. + of an approval it checks the shape, and the executors verify, + the hub itself for a choice answer when it sends it. """ if not signed: if proof is not None: raise ToolError("proof must be null when no item needs a signature") return None if not isinstance(proof, dict): - raise ToolError("nicht signiert: an approval of an ask, review or operator command needs the passkey signature") + raise ToolError("not signed: an approval of an ask, review, choice or operator command needs the passkey signature") expect(proof, ("payload", "assertion")) try: payload = signing.checked_approval(proof["payload"], now) @@ -136,15 +139,15 @@ class Approving: item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed, "note": None} batch = self.batches.add([item], f"rule:{rule['id']}", rule.get("proof"), self.apply) words = f"{rule['kind']} {rule['tool']} {rule['pattern']}".replace(" ", " ") - self.log.add({"kind": "rule", "title": f"Regel gibt frei: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}}) + self.log.add({"kind": "rule", "title": f"Rule grants: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}}) def apply(self, batch: dict) -> None: for item in batch["items"]: try: outcome = self.settle_item(item, batch["proof"] if item["signed"] is not None else None) except (OSError, ToolError) as error: - outcome = f"fehlgeschlagen: {error}" - verb = "Freigabe" if item["verb"] == "approve" else "Ablehnung" + outcome = f"failed: {error}" + verb = "Grant" if item["verb"] == "approve" else "Denial" label = f" → {item['label']}" if item["label"] else "" ref = {"kind": item["kind"], "id": item["id"], "verb": item["verb"], "source": batch["source"], "outcome": outcome} self.log.add({"kind": "approval", "title": f"{verb} {item['kind']}: {item['title']}{label}", "text": f"{outcome} ({batch['source']})", "ref": ref}) @@ -158,15 +161,17 @@ class Approving: if item["kind"] == "ask": record = self.asks.get(item["id"]) if record is None or ask_state(record, time.time())["status"] == "expired": - return "abgelaufen" + return "expired" record, changed = self.asks.decide(item["id"], {"status": "allowed" if approve else "denied", "proof": proof}) elif item["kind"] == "review": record, changed = self.reviews.decide(item["id"], {"status": "approved" if approve else "rejected", "proof": proof}) + elif item["signed"] is not None: + return self.answer_choice(item, proof) else: record, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"]}) if record is None: - return "unbekannt" - return record["status"] if changed else f"schon {record['status']}" + return "unknown" + return record["status"] if changed else f"already {record['status']}" def requester(self, raw: dict, who: str) -> str: """Returns: @@ -216,9 +221,9 @@ class Approving: raise ToolError("a decision is approved with one of its option labels") label = raw["label"] if kind_name == "rule": - return inbox_item(raw, "", f"Regel {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"])) + return inbox_item(raw, "", f"Rule {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"])) title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name] - subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else None + subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else answer_signed(record, label) return inbox_item(raw, label, title, record.get("gate", ""), subject, self.requester(raw, record["session"] if kind_name == "ask" else "")) def checked(self, raw: object) -> list[dict]: @@ -259,5 +264,5 @@ class Approving: def undo(self, batch_id: str) -> dict: batch = self.batches.undo(batch_id) titles = ", ".join(item["title"][:80] for item in batch["items"]) - self.log.add({"kind": "approval", "title": f"RĂŒckgĂ€ngig: {titles}", "text": batch["source"], "ref": {"batch": batch_id}}) + self.log.add({"kind": "approval", "title": f"Undone: {titles}", "text": batch["source"], "ref": {"batch": batch_id}}) return {"id": batch_id, "status": "undone"} diff --git a/orchestrator/kvb_orchestrator/asking.py b/orchestrator/kvb_orchestrator/asking.py index 8128294..49ec1c5 100644 --- a/orchestrator/kvb_orchestrator/asking.py +++ b/orchestrator/kvb_orchestrator/asking.py @@ -2,6 +2,7 @@ from __future__ import annotations import time +from .choosing import EVERYONE, answer_text from .errors import Conflict, Missing, ToolError from .fields import ( COMMAND_LIMIT, @@ -35,7 +36,13 @@ def ask_row(ask: dict, names: dict[str, str]) -> dict: def decision_state(decision: dict) -> dict: - return {key: decision[key] for key in ("id", "status", "choice", "decided")} + """Returns: + The status and choice, with the id and text of the operator's + passkey-signed answer when a click chose it, else None for both. + """ + message = decision.get("message") + answer = None if message is None else answer_text(decision, decision["choice"]) + return {**{key: decision[key] for key in ("id", "status", "choice", "decided")}, "message": message, "answer": answer} def decision_row(decision: dict) -> dict: @@ -87,20 +94,7 @@ class Asking: if not changed: raise Conflict(ask_state(decided, time.time())) return ask_state(decided, time.time()) - if kind_name != "decisions" or verb != "choose": - raise Missing(verb) - payload = decode(body) - expect(payload, ("label",)) - label = field(payload, "label") - record = self.decisions.get(item_id) - if record is None: - raise Missing(item_id) - if label not in [option["label"] for option in record["options"]]: - raise ToolError(f"label must be one of the options of decision {item_id}") - decided, changed = self.decisions.decide(item_id, {"status": "decided", "choice": label}) - if not changed: - raise Conflict(decision_state(decided)) - return decision_state(decided) + raise Missing(verb) def decision_request(self, arguments: dict) -> dict: expect(arguments, ("question", "options", "agent", "recommended")) @@ -114,7 +108,10 @@ class Asking: recommended = arguments["recommended"] if not isinstance(recommended, str) or recommended.strip() not in ["", *(option["label"] for option in parsed)]: raise ToolError("recommended must be one of the option labels or an empty string") - fields = {"question": question, "options": parsed, "agent": field(arguments, "agent").strip(), "recommended": recommended.strip()} + asker = field(arguments, "agent").strip() + if EVERYONE in asker: + raise ToolError(f"agent names the asking agent, never a name with {EVERYONE}") + fields = {"question": question, "options": parsed, "agent": asker, "recommended": recommended.strip()} record = self.decisions.add({**fields, "status": "pending", "choice": None, "decided": None, "created_ts": time.time()}) self.automate("decision", record) return {"id": record["id"], "status": record["status"], "url": self.url()} diff --git a/orchestrator/kvb_orchestrator/choosing.py b/orchestrator/kvb_orchestrator/choosing.py new file mode 100644 index 0000000..a5b1038 --- /dev/null +++ b/orchestrator/kvb_orchestrator/choosing.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from .council import agent +from .errors import ToolError +from .fields import decode, expect, field +from .messaging import DIRECT, EVERYONE, TO_ALL +from .signed import signing + +CHOICE_REQUEST = "send me your open decisions as choices" +CHANNELS = {**DIRECT, **TO_ALL} +CHOICE_KEYS = ("class", "to") +RECIPIENT_LIMIT = 40 +ANSWER_CLASS = "post" +QUESTION_SHOWN = 200 + + +def answer_text(decision: dict, label: str) -> str: + question = " ".join(decision["question"].split())[:QUESTION_SHOWN] + return f"Choice for decision {decision['id']} ({question}): {label}" + + +def answer_signed(decision: dict, label: str) -> dict: + """Returns: + The post to the asking agent that a click on the option label + signs: the operator's answer operator_message proves. + """ + if EVERYONE in decision["agent"]: + raise ToolError(f"a choice answer goes to the asking agent, never to a name with {EVERYONE}") + return signing.message_item(agent(decision["agent"]), ANSWER_CLASS, answer_text(decision, label), "", "", "") + + +def recipients(payload: dict) -> list[str]: + """Args: + payload: the page's class and to: 1 to RECIPIENT_LIMIT distinct + agent names for post and call, [EVERYONE] for a class to all. + """ + cls, names = payload["class"], payload["to"] + if not isinstance(cls, str) or cls not in CHANNELS: + raise ToolError(f"class must be one of {', '.join(CHANNELS)}") + if not isinstance(names, list) or not all(isinstance(name, str) for name in names): + raise ToolError("to must be a list of agent names") + if cls in TO_ALL: + if names != [EVERYONE]: + raise ToolError(f"a choice request to all names to [{EVERYONE}]") + return names + targets = [field({"to": name}, "to").strip() for name in names] + if not 1 <= len(targets) <= RECIPIENT_LIMIT or any(EVERYONE in target for target in targets): + raise ToolError(f"a post or a call names 1 to {RECIPIENT_LIMIT} agents, never a name with {EVERYONE}") + if len({agent(target) for target in targets}) != len(targets): + raise ToolError("to must name distinct agents") + return targets + + +class Choosing: + def choice_items(self, payload: dict) -> list[dict]: + return [signing.message_item(target, payload["class"], CHOICE_REQUEST, "", "", "") for target in recipients(payload)] + + def sign_choices(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, CHOICE_KEYS) + return self.challenge_for(self.choice_items(payload)) + + def request_choices(self, body: bytes) -> dict: + """Returns: + The ids of the stored requests, one per recipient, once one + passkey assertion signed each of them; nothing is stored or sent + before every item verified. + """ + payload = decode(body) + expect(payload, (*CHOICE_KEYS, "proof")) + items = self.choice_items(payload) + for item in items: + self.operator_proven(payload["proof"], item) + records = [self.stored_message(item, payload["proof"], None) for item in items] + runs = [self.radioed(record, CHANNELS[payload["class"]]) for record in records] + return {"ids": [record["id"] for record in records], "ok": all(run["code"] == 0 for run in runs), "runs": runs} + + def answer_choice(self, item: dict, proof: dict) -> str: + """Args: + item: a checked decision item whose signed is answer_signed(). + proof: the relayed signature; the hub sends the answer itself, + so it verifies the signature here, once. + + Returns: + The outcome words for the stream. + """ + record = self.decisions.get(item["id"]) + if record is None: + return "unknown" + if record["status"] != "pending": + return f"already {record['status']}" + self.operator_proven(proof, item["signed"]) + message = self.stored_message(item["signed"], proof, None) + decided, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"], "message": message["id"]}) + if not changed: + self.messages.remove(message["id"]) + return f"already {decided['status']}" + run = self.radioed(message, CHANNELS[ANSWER_CLASS]) + where = f"{ANSWER_CLASS} to {message['address']}" + return f"decided · {where}" if run["code"] == 0 else f"decided · {where} failed ({run['code']})" diff --git a/orchestrator/kvb_orchestrator/cleanup.py b/orchestrator/kvb_orchestrator/cleanup.py index eade53e..6cdb85f 100644 --- a/orchestrator/kvb_orchestrator/cleanup.py +++ b/orchestrator/kvb_orchestrator/cleanup.py @@ -71,7 +71,7 @@ class Cleaning: raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects") scan = self.tree.get() if scan.get("delete_ready") is not True: - raise ToolError(f"löschen gesperrt: {scan.get('delete_reason') or scan.get('error') or 'delete_ready ist false'}") + raise ToolError(f"delete blocked: {scan.get('delete_reason') or scan.get('error') or 'delete_ready is false'}") known = offered(scan) chosen = [] for entry in raw: @@ -94,7 +94,7 @@ class Cleaning: job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{key: item[key] for key in JOB_KEYS} for item in items], "proof": proof} self.spool.put(job_id, job) names = ", ".join(item["branch"] for item in items) - self.log.add({"kind": "run", "title": f"Löschen signiert: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}}) + self.log.add({"kind": "run", "title": f"Delete signed: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}}) return {"id": job_id, "status": "queued", "items": job["items"]} def cleanup_state(self, job_id: str) -> dict: diff --git a/orchestrator/kvb_orchestrator/council.py b/orchestrator/kvb_orchestrator/council.py index 1de6f13..9293520 100644 --- a/orchestrator/kvb_orchestrator/council.py +++ b/orchestrator/kvb_orchestrator/council.py @@ -14,7 +14,8 @@ CLASSES = ("post", "call", "newsletter", "broadcast", "mayday", "pan-pan", "secu RECEIPT = "receipt" ALARMS = ("mayday", "pan-pan", "security") VERSION = 2 -MODES = ("evening", "sunset") +MODES = ("drain", "suspend") +RENAMED_MODES = {"evening": "drain", "sunset": "suspend"} RADIO_LIMIT = 5000 GRANT_LIMIT = 50 MAYDAY_ACTIVE = 7200 @@ -154,8 +155,17 @@ def grants(rows: list[dict], window: tuple[float, float], claims: list[dict], ta def night_mode(council: Path) -> str: + """Args: + council: the council directory; night.json in it may still hold a + mode from before the rename, read as its successor. + + Returns: + One of MODES, or day. + """ data = read_json(council / "night.json") mode = data.get("mode") if isinstance(data, dict) else None + if isinstance(mode, str): + mode = RENAMED_MODES.get(mode, mode) return mode if mode in MODES else "day" diff --git a/orchestrator/kvb_orchestrator/crons.py b/orchestrator/kvb_orchestrator/crons.py index a5b907d..04240e5 100644 --- a/orchestrator/kvb_orchestrator/crons.py +++ b/orchestrator/kvb_orchestrator/crons.py @@ -9,7 +9,7 @@ from .records import epoch, read_json, shown CRON_ACTIVE = 900 TIMER_KEYS = ("OnCalendar", "OnActiveSec", "OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec") -CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Hintergrund", "timer": "Timer", "queue": "Queue"} +CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Background", "timer": "Timer", "queue": "Queue"} def cron_state(cron: dict, beats: dict[str, float], now: float) -> dict: @@ -59,7 +59,7 @@ def queue_entries(council: Path) -> list[tuple[dict, float]]: def queue_rows(council: Path) -> list[dict]: rows = [] for entry, created in queue_entries(council): - running = "lĂ€uft" if entry.get("status") == "active" else "wartet" + running = "running" if entry.get("status") == "active" else "waiting" rows.append({ "key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created, "deleted": None, "until": None, "job": shown(entry.get("id")), "expression": f"{shown(entry.get('kind'))} · {running}", diff --git a/orchestrator/kvb_orchestrator/denying.py b/orchestrator/kvb_orchestrator/denying.py index 0867b14..5958f84 100644 --- a/orchestrator/kvb_orchestrator/denying.py +++ b/orchestrator/kvb_orchestrator/denying.py @@ -76,28 +76,28 @@ class Denying: tasks = read_register(self.council / REGISTER) row = next((row for row in operator_rows(tasks) if row["task"] == item["id"]), None) if row is None or signing.denial_item(operator_signed(row), note["channel"], note["reason"], note["to"]) != item["signed"]: - return "verweigert: die Zeile hat sich seit der Ablehnung geĂ€ndert" + return "refused: the row changed since the denial" if item["id"] in self.open_jobs(): - return "lĂ€uft schon" + return "already running" run = self.scripts.run(RASCI, ["set", item["id"], "--status", "stopped"]) if run["code"] != 0: - return f"fehlgeschlagen: {run['command']} → {run['code']}" + return f"failed: {run['command']} → {run['code']}" command, directory = row["command"][:200], row["directory"] - return f"gestoppt · {self.tell(item, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}" + return f"stopped · {self.tell(item, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}" ledger = self.asks if item["kind"] == "ask" else self.rule_requests record = ledger.get(item["id"]) if record is None: - return "unbekannt" + return "unknown" now = time.time() if (ask_state(record, now)["status"] if item["kind"] == "ask" else request_status(record, now)) == "expired": - return "abgelaufen" + return "expired" record, changed = ledger.decide(item["id"], {"status": "denied", "proof": proof, **note}) if not changed: - return f"schon {record['status']}" + return f"already {record['status']}" if item["kind"] == "ask": tool, command = record["tool"], record["command"][:200] - return f"abgelehnt · {self.tell(item, proof, record['session'], f'ask {tool}: {command}')}" - return f"abgelehnt · {self.tell(item, proof, record['agent'], f'rule request {request_words(record)}')}" + return f"denied · {self.tell(item, proof, record['session'], f'ask {tool}: {command}')}" + return f"denied · {self.tell(item, proof, record['agent'], f'rule request {request_words(record)}')}" def addressee(self, who: str) -> tuple[str, str]: return requester_of(read_claims(self.council), who) @@ -120,11 +120,11 @@ class Denying: direct = channel in DIRECT address = self.addressee(note["to"])[1] if direct else EVERYONE if direct and not address: - return f"kein EmpfĂ€nger fĂŒr {channel}" + return f"no recipient for {channel}" text = denial_text(what, self.addressee(who)[0], note["reason"]) - where = f"{channel} an {address}" if direct else f"{channel} an alle" + where = f"{channel} to {address}" if direct else f"{channel} to all" fields = {"to": note["to"], "address": address, "text": text, "upload": None, "voice": None, "voice_sha256": "", "class": channel} record = self.messages.add({**fields, "denial": item["signed"], "proof": proof, "created_ts": time.time()}) targets = ["--to", address] if direct else [] run = self.scripts.run(RADIO, [*DENY_CHANNELS[channel], *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"]) - return where if run["code"] == 0 else f"{where} fehlgeschlagen ({run['code']})" + return where if run["code"] == 0 else f"{where} failed ({run['code']})" diff --git a/orchestrator/kvb_orchestrator/events.py b/orchestrator/kvb_orchestrator/events.py index a7ea702..3cc4ff6 100644 --- a/orchestrator/kvb_orchestrator/events.py +++ b/orchestrator/kvb_orchestrator/events.py @@ -17,18 +17,18 @@ from .register import status_of from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS KINDS = ( - ("radio", "Funk"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Entscheidungen"), ("operator", "Operator"), ("register", "Register"), - ("rename", "Namen"), ("cron", "Crons"), ("rule", "Regeln"), ("approval", "Freigaben"), ("run", "Runner"), ("transcript", "Transkripte"), + ("radio", "Radio"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Decisions"), ("operator", "Operator"), ("register", "Register"), + ("rename", "Names"), ("cron", "Crons"), ("rule", "Firewall"), ("approval", "Grants"), ("run", "Runner"), ("transcript", "Transcripts"), ) EVENT_KINDS = tuple(name for name, _ in KINDS) WORK = [name for name in EVENT_KINDS if name not in ("radio", "transcript")] VIEWS = { - "fuer-dich": {"label": "FĂŒr dich", "kinds": ["radio", *WORK], "waiting": True}, - "funk": {"label": "Funk", "kinds": ["radio"], "waiting": False}, - "arbeit": {"label": "Arbeit", "kinds": WORK, "waiting": False}, - "transkripte": {"label": "Transkripte", "kinds": ["transcript"], "waiting": False}, + "fuer-dich": {"label": "For you", "kinds": ["radio", *WORK], "waiting": True}, + "funk": {"label": "Radio", "kinds": ["radio"], "waiting": False}, + "arbeit": {"label": "Work", "kinds": WORK, "waiting": False}, + "transkripte": {"label": "Transcripts", "kinds": ["transcript"], "waiting": False}, } -ART = {word: name for name, label in KINDS for word in (name, label.lower())} +KIND_WORDS = {word: name for name, label in KINDS for word in (name, label.lower())} SPAN = re.compile(r"(\d{1,4})([mhd])") UNITS = {"m": 60, "h": 3600, "d": 86400} @@ -44,14 +44,14 @@ def since_of(word: str, now: float) -> float: try: return datetime.datetime.fromisoformat(word).timestamp() except ValueError: - raise ToolError("seit: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None + raise ToolError("since: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None def search(text: str, now: float) -> dict: """Args: - text: the search line: words, `agent:`, `art:` - and `seit:`. - now: the clock `seit:` spans count back from. + text: the search line: words, `agent:`, `kind:` + and `since:`. + now: the clock `since:` spans count back from. Returns: The plain words as `q`, plus `agent`, `kinds` and `since` for each @@ -60,16 +60,16 @@ def search(text: str, now: float) -> dict: found: dict = {"q": []} for word in text.split(): key, colon, value = word.partition(":") - if not colon or key not in ("agent", "art", "seit"): + if not colon or key not in ("agent", "kind", "since"): found["q"].append(word) elif not value: raise ToolError(f"{key}: needs a value") elif key == "agent": found["agent"] = value - elif key == "art": - if value.lower() not in ART: - raise ToolError(f"art: takes one of {', '.join(EVENT_KINDS)}") - found.setdefault("kinds", set()).add(ART[value.lower()]) + elif key == "kind": + if value.lower() not in KIND_WORDS: + raise ToolError(f"kind: takes one of {', '.join(EVENT_KINDS)}") + found.setdefault("kinds", set()).add(KIND_WORDS[value.lower()]) else: found["since"] = since_of(value, now) found["q"] = " ".join(found["q"]).lower() @@ -128,7 +128,7 @@ def register_changes(before: dict[str, dict], after: dict[str, dict], now: float continue if old is None or new is None: row = new if old is None else old - change = f"neu: {shown(row.get('title'))}" if old is None else "entfernt" + change = f"new: {shown(row.get('title'))}" if old is None else "removed" else: keys = sorted(key for key in set(old) | set(new) if old.get(key) != new.get(key)) change = "; ".join(f"{key}: {described(old.get(key))} → {described(new.get(key))}" for key in keys) diff --git a/orchestrator/kvb_orchestrator/handler.py b/orchestrator/kvb_orchestrator/handler.py index 40abc94..c272289 100644 --- a/orchestrator/kvb_orchestrator/handler.py +++ b/orchestrator/kvb_orchestrator/handler.py @@ -17,7 +17,7 @@ TTS_ROUTE = "/api/tts" WAV_TYPE = "audio/wav" STT_ROUTE = "/api/stt" ROUTE = re.compile(r"/reviews/([0-9a-f]{16})/(page|files|approve|reject)") -ITEM = re.compile(r"/api/(asks|decisions)/([0-9a-f]{16})(?:/(allow|deny|choose))?") +ITEM = re.compile(r"/api/(asks|decisions)/([0-9a-f]{16})(?:/(allow|deny))?") ENTRY = re.compile(r"/api/stream/([0-9]{1,12})") BOARD = re.compile(r"/api/board/([a-z-]{1,32})") UPLOADS = "/api/uploads" @@ -48,8 +48,10 @@ VOICE_ITEM = re.compile(r"/api/voice/([0-9a-f]{16})(\.wav)?") MESSAGE_SIGN_ROUTE = "/api/messages/sign" BOARD_SIGN_ROUTE = "/api/board/sign" SETTINGS_SIGN_ROUTE = "/api/settings/sign" +CHOICES_ROUTE = "/api/choices" +CHOICES_SIGN_ROUTE = "/api/choices/sign" WAV_POSTS = (STT_ROUTE, VOICE_ROUTE) -ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE) +ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE) class Handler(Exchange): @@ -182,6 +184,10 @@ class Handler(Exchange): return lambda body: self.answer(lambda: hub.voice_notes.submit(body), HTTPStatus.ACCEPTED) if path == MESSAGE_SIGN_ROUTE: return lambda body: self.answer(lambda: hub.sign_message(body), HTTPStatus.OK) + if path == CHOICES_SIGN_ROUTE: + return lambda body: self.answer(lambda: hub.sign_choices(body), HTTPStatus.OK) + if path == CHOICES_ROUTE: + return lambda body: self.answer(lambda: hub.request_choices(body), HTTPStatus.CREATED) return None def posted(self, path: str) -> bool: diff --git a/orchestrator/kvb_orchestrator/hub.py b/orchestrator/kvb_orchestrator/hub.py index 4ed47a1..e9d49af 100644 --- a/orchestrator/kvb_orchestrator/hub.py +++ b/orchestrator/kvb_orchestrator/hub.py @@ -11,6 +11,7 @@ from .approving import Approving from .artifacts import Touched from .asking import Asking from .batches import Pending +from .choosing import Choosing from .cleanup import Cleaning, Spool from .denying import Denying from .engines import Engines, Speech @@ -62,7 +63,7 @@ def environment(templates: Path) -> jinja2.Environment: @dataclass(frozen=True) -class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language): +class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language): council: Path pages: Path public_port: int diff --git a/orchestrator/kvb_orchestrator/jobs.py b/orchestrator/kvb_orchestrator/jobs.py index 6270863..dd4876b 100644 --- a/orchestrator/kvb_orchestrator/jobs.py +++ b/orchestrator/kvb_orchestrator/jobs.py @@ -54,12 +54,12 @@ class Jobs: def queue_job(self, item: dict, proof: dict | None) -> str: row = next((row for row in operator_rows(read_register(self.council / REGISTER)) if row["task"] == item["id"]), None) if row is None or not runnable(row) or operator_signed(row) != item["signed"]: - return "verweigert: die Zeile hat sich seit der Freigabe geĂ€ndert" + return "refused: the row changed since the grant" if item["id"] in self.open_jobs(): - return "lĂ€uft schon" + return "already running" job = {"task": row["task"], "command": row["command"], "directory": row["directory"], "sha256": item["sha256"], "status": "queued", "proof": proof} self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None, "dismissed": False}) - return "an den Runner ĂŒbergeben" + return "handed to the runner" def runner_jobs(self) -> dict: return {"jobs": [runner_job(job) for job in self.jobs.listed() if job["status"] == "queued"]} diff --git a/orchestrator/kvb_orchestrator/language.py b/orchestrator/kvb_orchestrator/language.py index 5e8382b..8259d56 100644 --- a/orchestrator/kvb_orchestrator/language.py +++ b/orchestrator/kvb_orchestrator/language.py @@ -87,36 +87,36 @@ def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict: raise ToolError("that operator row holds no command to run") chosen = [nl_item(item, "approve" if name == "approve" else "reject", item["recommended"] if item["kind"] == "decision" else "") for item in picked] lines = [f"{item['kind']} {item['id']}: {item['command'] if item['kind'] == 'operator' else item['title']}" for item in picked] - verb = "Freigeben" if name == "approve" else "Ablehnen" + verb = "Grant" if name == "approve" else "Deny" return {"name": name, "summary": f"{verb}:\n" + "\n".join(lines), "args": {"items": chosen}} if name == "choose": item = items.get(arguments["id"]) if isinstance(arguments["id"], str) else None if item is None or item["kind"] != "decision" or arguments["label"] not in [option["label"] for option in item["options"]]: raise ToolError("choose takes a decision id and one of its option labels") - return {"name": name, "summary": f"Entscheiden: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}} + return {"name": name, "summary": f"Decide: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}} if name in ("snooze", "pin"): picked = nl_ids(arguments, items) hours = arguments.get("hours") if name == "snooze" and (isinstance(hours, bool) or not isinstance(hours, int | float) or not 1 <= hours <= 192): raise ToolError("hours must be between 1 and 192") keys = [{"kind": item["kind"], "id": item["id"]} for item in picked] - words = f"ZurĂŒckstellen ({hours} h)" if name == "snooze" else "Anheften" + words = f"Snooze ({hours} h)" if name == "snooze" else "Pin" return {"name": name, "summary": f"{words}: " + ", ".join(item["title"][:80] for item in picked), "args": {"keys": keys, "hours": hours}} if name == "view": if arguments["name"] not in VIEWS: raise ToolError("unknown view") - return {"name": name, "summary": f"Ansicht: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}} + return {"name": name, "summary": f"View: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}} if name == "open": if arguments["view"] not in SCREENS: raise ToolError("unknown screen") - return {"name": name, "summary": f"Öffnen: {arguments['view']}", "args": {"view": arguments["view"]}} + return {"name": name, "summary": f"Open: {arguments['view']}", "args": {"view": arguments["view"]}} if name == "message": text = message_text(arguments, "text") if arguments["to"] not in agents: raise ToolError("to must name a known agent") - return {"name": name, "summary": f"Funk an {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}} + return {"name": name, "summary": f"Radio to {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}} query = bounded(arguments, "q", 200) - return {"name": name, "summary": f"Suchen: {query}", "args": {"q": query}} + return {"name": name, "summary": f"Search: {query}", "args": {"q": query}} def nl_actions(reply: str, items: list[dict], agents: list[str]) -> dict: diff --git a/orchestrator/kvb_orchestrator/mcp.py b/orchestrator/kvb_orchestrator/mcp.py index d476521..b90b5ac 100644 --- a/orchestrator/kvb_orchestrator/mcp.py +++ b/orchestrator/kvb_orchestrator/mcp.py @@ -5,6 +5,7 @@ from http import HTTPStatus from . import NAME from .asking import OPTION_LIMIT +from .choosing import CHOICE_REQUEST from .errors import RpcError, ToolError from .fields import WAIT_CAP from .requesting import AGENT_CAP @@ -64,19 +65,22 @@ class Mcp: tool("review_list", "List the pending reviews.", {}), tool( "decision_request", - "Ask the operator to pick one of several options in the Agent Orchestrator; each option becomes a button. " - "Returns the decision id and the hub URL.", + "Ask the operator to pick one of several options in the Agent Orchestrator; the question shows as a choice card " + "under Waits on Operator in the Tasks tab and each option becomes a button. When an operator message asks " + f'"{CHOICE_REQUEST}", send each open decision of kvb-think-choice through this tool. Returns the decision id and the hub URL.', { "question": text_property("The question, plain text."), "options": {"type": "array", "minItems": 1, "maxItems": OPTION_LIMIT, "items": option, "description": "The choices, at least one."}, - "agent": text_property("Your agent name, shown with the question."), + "agent": text_property("Your agent name, shown with the question; the operator's signed answer is a post to it."), "recommended": text_property("The label of the option you recommend, or an empty string."), }, ), tool( "decision_wait", - f"Wait until the operator picks an option or the timeout passes, then return the status " - f"(pending, decided) and the chosen label. Waits at most {WAIT_CAP} seconds.", + "Wait until the operator picks an option or the timeout passes, then return the status " + "(pending, decided), the chosen label and, for a click, message and answer: the id and text of the operator's " + "passkey-signed answer, a post to the asking agent; check it with operator_message(message, answer, your name) " + f"before acting on the choice. Waits at most {WAIT_CAP} seconds.", {"id": text_property("Decision id that decision_request returned."), "timeout_seconds": wait}, ), tool( diff --git a/orchestrator/kvb_orchestrator/messaging.py b/orchestrator/kvb_orchestrator/messaging.py index c1aaeee..2e1689b 100644 --- a/orchestrator/kvb_orchestrator/messaging.py +++ b/orchestrator/kvb_orchestrator/messaging.py @@ -5,17 +5,17 @@ import secrets import time from .council import HUMAN, agent, ordered, read_claims -from .errors import Missing, ToolError +from .errors import Conflict, Missing, ToolError from .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single from .records import write_atomic from .register import REGISTER, agent_order, read_register from .signed import message_signed, signing -from .skill_scripts import NIGHT, RADIO, RASCI +from .skill_scripts import ESTIMATE, NIGHT, RADIO, RASCI NIGHTS = { - "evening": (["set", "evening"], "Operator ran kvb-org-evening in the Agent Orchestrator: run kvb-agent-relax; verify with night.py status."), - "sunset": (["set", "sunset"], "Operator ran kvb-org-sunset in the Agent Orchestrator: run kvb-agent-sleep; verify with night.py status."), - "sunrise": (["clear"], "Operator ran kvb-org-sunrise: run kvb-agent-wakeup; verify with night.py status."), + "drain": (["drain"], "Operator ran kvb-org-drain in the Agent Orchestrator: run kvb-agent-drain; verify with night.py status."), + "suspend": (["suspend"], "Operator ran kvb-org-suspend in the Agent Orchestrator: run kvb-agent-suspend; verify with night.py status."), + "resume": (["resume"], "Operator ran kvb-org-resume: run kvb-agent-resume; verify with night.py status."), } UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") DIRECT = {"post": ["post"], "call": ["send", "call"]} @@ -23,7 +23,7 @@ TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", EVERYONE = "*" DENIED = "Denied by the operator." MESSAGE_KEYS = ("to", "text", "upload", "class", "voice") -SIGNED_BOARD = ("clear-mayday", *NIGHTS) +SIGNED_BOARD = ("clear-mayday", *NIGHTS, "estimates") def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]: @@ -142,17 +142,31 @@ class Messaging: item, _, _ = self.composed(payload, {**DIRECT, **TO_ALL}) return self.challenge_for([item]) + def stored_message(self, item: dict, proof: dict, upload: dict | None) -> dict: + """Args: + item: the message item a pinned key signed. + proof: that signature, kept so operator_message can prove it. + upload: the attached upload or None. + + Returns: + The stored record, addressed to the radio address the claims + give the recipient. + """ + target = item["to"] + claims = read_claims(self.council) + address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target) + fields = {"to": target, "address": address, "text": item["text"], "upload": upload, "voice": item["voice"] or None, "voice_sha256": item["voice_sha256"], "class": item["class"]} + return self.messages.add({**fields, "proof": proof, "created_ts": time.time()}) + + def radioed(self, record: dict, command: list[str]) -> dict: + targets = [] if record["to"] == EVERYONE else ["--to", record["address"]] + return self.scripts.run(RADIO, [*command, *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {self.relayed(record)}"]) + def relay(self, payload: dict, choices: dict[str, list[str]]) -> dict: with self.voice_notes.held(payload["voice"]): item, command, upload = self.composed(payload, choices) - proof = self.operator_proven(payload["proof"], item) - target = item["to"] - claims = read_claims(self.council) - address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target) - fields = {"to": target, "address": address, "text": item["text"], "upload": upload, "voice": item["voice"] or None, "voice_sha256": item["voice_sha256"], "class": item["class"]} - record = self.messages.add({**fields, "proof": proof, "created_ts": time.time()}) - targets = [] if target == EVERYONE else ["--to", address] - run = self.scripts.run(RADIO, [*command, *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {self.relayed(record)}"]) + record = self.stored_message(item, self.operator_proven(payload["proof"], item), upload) + run = self.radioed(record, command) return {"id": record["id"], "ok": run["code"] == 0, "runs": [run]} def message(self, body: bytes) -> dict: @@ -191,6 +205,11 @@ class Messaging: if unknown: raise ToolError(f"unknown agents: {', '.join(unknown)[:300]}; the register and the council know {', '.join(known)[:600] or 'none'}") runs = [self.scripts.run(RASCI, ["agents", "order", ",".join(names)])] + elif action == "estimates": + estimation = self.settings.get()["estimation"] + if estimation != "points": + raise Conflict({"estimation": estimation, "reason": "estimates are requested only in story points mode"}) + runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])] elif action in NIGHTS: command, text = NIGHTS[action] runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])] diff --git a/orchestrator/kvb_orchestrator/names.py b/orchestrator/kvb_orchestrator/names.py index a2bf2a1..43e1fbf 100644 --- a/orchestrator/kvb_orchestrator/names.py +++ b/orchestrator/kvb_orchestrator/names.py @@ -65,13 +65,18 @@ class Directory: def renames(self, begin: float, end: float) -> list[dict]: found = [] + shown = set() for session, own in self.by_session.items(): for before, after in itertools.pairwise(own): - if before["name"] != after["name"] and begin <= after["first_seen"] <= end: + pair = (session, before["name"], after["name"]) + if before["name"] == after["name"] or pair in shown: + continue + shown.add(pair) + if begin <= after["first_seen"] <= end: found.append(event({ "id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"], - "now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}", - "text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]}, + "now": self.now(session), "session": session, "title": f"{before['name']} is now called {after['name']}", + "text": f"Source: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]}, })) return found @@ -89,9 +94,20 @@ class Names: self.observed: dict[str, str] = observed if isinstance(observed, dict) else {} def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None: + """Args: + sightings: (session, name, seen, source); of several sightings of + one session from one source only the newest counts, so two + claims under different names cannot flip the name back and + forth on every state build. + """ + newest: dict[tuple[str, str], tuple[str, str, float | None, str]] = {} + for sighting in sightings: + key = (sighting[0], sighting[3]) + if key not in newest or (sighting[2] or -math.inf) >= (newest[key][2] or -math.inf): + newest[key] = sighting with self.lock: changed = False - for session, name, seen, source in sightings: + for session, name, seen, source in newest.values(): key = f"{session}\x00{source}" if not session or not name or self.observed.get(key) == name: continue diff --git a/orchestrator/kvb_orchestrator/offices.py b/orchestrator/kvb_orchestrator/offices.py index b2421ec..adb6aff 100644 --- a/orchestrator/kvb_orchestrator/offices.py +++ b/orchestrator/kvb_orchestrator/offices.py @@ -11,7 +11,7 @@ from .signed import signing ITEM_LIMIT = 20 JOB_VERSION = 1 -LABELS = {"appoint": "besetzt", "takeover": "ĂŒbergeben", "recall": "abberufen"} +LABELS = {"appoint": "appointed", "takeover": "handed over", "recall": "recalled"} def office_request(raw: object) -> bool: @@ -77,7 +77,7 @@ class Offices: self.office_spool.put(job_id, job) names = ", ".join(f"{item['office']} {LABELS[item['action']]}" for item in items) lines = "\n".join(f"{item['action']} {item['office']}: {item['holder'] or '-'} -> {item['session'] or '-'}" for item in items) - self.log.add({"kind": "run", "title": f"Amt signiert: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}}) + self.log.add({"kind": "run", "title": f"Office signed: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}}) return {"id": job_id, "status": "queued", "items": job["items"]} def office_state(self, job_id: str) -> dict: diff --git a/orchestrator/kvb_orchestrator/phrasing.py b/orchestrator/kvb_orchestrator/phrasing.py index 6d5e1c7..a2375bc 100644 --- a/orchestrator/kvb_orchestrator/phrasing.py +++ b/orchestrator/kvb_orchestrator/phrasing.py @@ -8,16 +8,38 @@ SPEECH_LIMIT = 1000 SPOKEN_TEXT = 500 SPOKEN_NAME = 60 SPOKEN_GROUP = 120 -ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"} +ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "say-cure-ee-tay"} ADDRESSED = ("post", "call", "receipt") CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE) HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]") HUB_FILE = re.compile(r"\s+file: /\S*$") SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT)) +HASH = re.compile(r"\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,}\b|[0-9]", re.IGNORECASE) +NATO = dict(zip("abcdef", ("alfa", "bravo", "charlie", "delta", "echo", "foxtrot"))) +ICAO = dict(zip("0123456789", ("zero", "one", "two", "tree", "four", "fife", "six", "seven", "eight", "niner"))) + + +def pronounced(text: str) -> str: + """Args: + text: a radio text as displayed. + + Returns: + The text with each hex run of 7 or more characters holding a letter + and a digit spelled in the NATO alphabet and every other digit + spoken on its own in ICAO pronunciation. + """ + return HASH.sub(spelled, text) + + +def spelled(found: re.Match) -> str: + start, end = found.span() + before = " " if start and found.string[start - 1].isalnum() else "" + after = " " if end < len(found.string) and found.string[end].isalnum() else "" + return before + " ".join(NATO.get(char) or ICAO[char] for char in found[0].lower()) + after def clip(text: str, limit: int) -> str: - words = " ".join(text.split()) + words = " ".join(pronounced(text).split()) if len(words) <= limit: return words return words[:limit].rsplit(" ", 1)[0] or words[:limit] diff --git a/orchestrator/kvb_orchestrator/requesting.py b/orchestrator/kvb_orchestrator/requesting.py index 5db7cf7..452ba22 100644 --- a/orchestrator/kvb_orchestrator/requesting.py +++ b/orchestrator/kvb_orchestrator/requesting.py @@ -49,7 +49,7 @@ class Requesting: raise ToolError(f"{name} already has {AGENT_CAP} pending rule requests; wait for their answer with rule_request_wait") record = self.rule_requests.add_checked({**fields, "agent": name, "why": why, "status": "pending", "rule": None, "reason": "", "channel": "", "proof": None, "decided": None, "created_ts": now}, capped) - self.log.add({"kind": "rule", "title": f"Regelwunsch von {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}}) + self.log.add({"kind": "rule", "title": f"Rule request from {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}}) return {"id": record["id"], "status": record["status"], "url": self.url()} def rule_request_wait(self, arguments: dict) -> dict: @@ -78,7 +78,7 @@ class Requesting: items = [] for record in self.open_requests(now): rule = {key: record[key] for key in RULE_FIELDS} - fields = {"title": f"Regel: {request_words(record)}", "agent": record["agent"], "to": requester_of(claims, record["agent"])[0], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True} + fields = {"title": f"Rule: {request_words(record)}", "agent": record["agent"], "to": requester_of(claims, record["agent"])[0], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True} items.append({**base, "kind": "rule", "id": record["id"], "ts": record["created_ts"], **fields}) return items @@ -113,5 +113,5 @@ class Requesting: if not changed: raise Conflict(request_state(decided, now)) self.store_rule(rule, fields, proof, True, now) - self.log.add({"kind": "rule", "title": f"Regelwunsch angenommen: {request_words(record)}", "text": f"von {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}}) + self.log.add({"kind": "rule", "title": f"Rule request accepted: {request_words(record)}", "text": f"from {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}}) return request_state(decided, now) diff --git a/orchestrator/kvb_orchestrator/rules.py b/orchestrator/kvb_orchestrator/rules.py index 1e438a5..f9bb79a 100644 --- a/orchestrator/kvb_orchestrator/rules.py +++ b/orchestrator/kvb_orchestrator/rules.py @@ -72,7 +72,7 @@ class Rules: try: passkey.verify_assertion(assertion, rule, verifier.pinned(self.approvers), signing.origin_of(self.public_port)) except signing.Refused as error: - raise ToolError(f"nicht signiert: {error}") from None + raise ToolError(f"not signed: {error}") from None return {"payload": rule, "assertion": assertion} def store_rule(self, rule: dict, fields: dict, proof: dict, enabled: bool, now: float) -> dict: diff --git a/orchestrator/kvb_orchestrator/settings.py b/orchestrator/kvb_orchestrator/settings.py index 4c180c9..a82f08e 100644 --- a/orchestrator/kvb_orchestrator/settings.py +++ b/orchestrator/kvb_orchestrator/settings.py @@ -14,6 +14,7 @@ from .fields import FIELDS, NAME_LIMIT, expect from .records import read_json, write_atomic THEMES = ("sky", "light", "dark", "custom") +ESTIMATIONS = ("none", "points") TONES = ("mayday", "review", "direct", "radio") COLOR = re.compile(r"#[0-9a-f]{6}") CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]") @@ -36,6 +37,7 @@ SETTINGS = { "direct": {"on": True, "volume": 0.5}, "radio": {"on": False, "volume": 0.25}, }, + "squelch": 1.0, "notify": True, "streams": [], "speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []}, @@ -43,6 +45,7 @@ SETTINGS = { "graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0}, "sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0}, }, + "estimation": "none", } SPACE_AXES = ("none", "time", "spawn") SPACE_LIMITS = { @@ -188,6 +191,8 @@ def valid_settings(raw: object) -> dict: raise ToolError(f"theme must be one of {', '.join(THEMES)}") if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT: raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries") + if not isinstance(data["estimation"], str) or data["estimation"] not in ESTIMATIONS: + raise ToolError(f"estimation must be one of {', '.join(ESTIMATIONS)}") return { "latitude": number_in(data["latitude"], "latitude", -90.0, 90.0), "longitude": number_in(data["longitude"], "longitude", -180.0, 180.0), @@ -195,10 +200,12 @@ def valid_settings(raw: object) -> dict: "colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]}, "music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)}, "sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES}, + "squelch": number_in(data["squelch"], "squelch", 0.0, 1.0), "notify": flag(data["notify"], "notify"), "streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])], "speech": speech_setting(data["speech"]), "space": space_setting(data["space"]), + "estimation": data["estimation"], } diff --git a/orchestrator/kvb_orchestrator/skill_scripts.py b/orchestrator/kvb_orchestrator/skill_scripts.py index 9e0c0e7..d606d0b 100644 --- a/orchestrator/kvb_orchestrator/skill_scripts.py +++ b/orchestrator/kvb_orchestrator/skill_scripts.py @@ -11,8 +11,9 @@ OUTPUT_LIMIT = 4000 SAFE_PATH = "/usr/local/bin:/usr/bin:/bin" RADIO = "kvb-org-radio/scripts/radio.py" RASCI = "kvb-org-rasci/scripts/rasci.py" -NIGHT = "kvb-org-sunset/scripts/night.py" +NIGHT = "kvb-org-suspend/scripts/night.py" OPERATOR_SCRIPT = "kvb-org-operator/scripts/operator.py" +ESTIMATE = "kvb-org-queue-estimate/scripts/estimate.py" class Runner(Protocol): diff --git a/orchestrator/kvb_orchestrator/tasking.py b/orchestrator/kvb_orchestrator/tasking.py index ae31c70..0180cba 100644 --- a/orchestrator/kvb_orchestrator/tasking.py +++ b/orchestrator/kvb_orchestrator/tasking.py @@ -9,7 +9,7 @@ from .register import REGISTER, read_register, status_of from .signed import signing from .tasks import editable -CHANGE_LIMIT = 10 +CHANGE_LIMIT = 100 CHANGE_KEYS = ("kind", "id", "argv") @@ -25,7 +25,9 @@ class Tasking: def task_changes(self, raw: object) -> list[dict]: """Args: raw: the page's items, each {"kind": "task", "id", "argv"} with - the rasci.py arguments of one priority pin or WSJF estimate. + the rasci.py arguments of one priority pin, WSJF estimate or + operator business value; a bulk change over a selection is + one item per task under one passkey assertion. Returns: The signed items, each for an open register task outside @@ -60,11 +62,11 @@ class Tasking: items = self.task_changes(payload["items"]) proof = self.covered(payload["proof"], items, time.time()) for item in items: - self.still_signed(proof, item) + self.operator_proven(proof, item) ids = [] for item in items: fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof} ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"]) commands = [shown_command(item["argv"]) for item in items] - self.log.add({"kind": "run", "title": f"Aufgabe signiert geĂ€ndert: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}}) + self.log.add({"kind": "run", "title": f"Task change signed: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}}) return {"jobs": ids, "status": "queued"} diff --git a/orchestrator/kvb_orchestrator/tasks.py b/orchestrator/kvb_orchestrator/tasks.py index e680e1a..15c9af2 100644 --- a/orchestrator/kvb_orchestrator/tasks.py +++ b/orchestrator/kvb_orchestrator/tasks.py @@ -3,8 +3,8 @@ from __future__ import annotations import itertools import statistics -from .council import agent -from .records import iso_epoch, shown +from .council import HUMAN, agent +from .records import epoch, iso_epoch, shown from .register import ENDED, OPERATOR, parties, register_row, status_of from .signed import signing from .tree import inside @@ -13,11 +13,13 @@ ENDED_SHOWN = 100 OPEN_SHOWN = 300 QUEUE_SHOWN = 100 PARTIES_SHOWN = 40 +AFTER_SHOWN = 40 TEXT_SHOWN = 500 FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89) LETTERS = "RASCI" HOTFIX = "hotfix/" QUEUE = "queue/" +HOUR = 3600 def kind_of(task: str) -> str: @@ -36,23 +38,42 @@ def fibonacci(value: object) -> bool: def estimates_of(row: dict) -> dict[str, dict]: + """Returns: + Each party's Fibonacci business value and complexity; the + Operator's complexity is None where it recorded a business value + alone. + """ raw = row.get("estimates") found = {} for party, entry in itertools.islice(raw.items(), PARTIES_SHOWN) if isinstance(raw, dict) else (): - if isinstance(entry, dict) and fibonacci(entry.get("value")) and fibonacci(entry.get("complexity")): - found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry["complexity"]} + alone = party == HUMAN and isinstance(entry, dict) and "complexity" not in entry + if isinstance(entry, dict) and fibonacci(entry.get("value")) and (alone or fibonacci(entry.get("complexity"))): + found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry.get("complexity")} return found +def business_value(estimates: dict[str, dict]) -> float | None: + if HUMAN in estimates: + return estimates[HUMAN]["value"] + return statistics.median(entry["value"] for entry in estimates.values()) if estimates else None + + +def points(estimates: dict[str, dict]) -> float | None: + sized = [entry["complexity"] for entry in estimates.values() if entry["complexity"] is not None] + return statistics.median(sized) if sized else None + + def wsjf(estimates: dict[str, dict]) -> dict | None: """Returns: - Median business value, median complexity and their quotient, the - same reading as rasci.py's wsjf(); None without an estimate. + Business value, median complexity and their quotient, the same + reading as rasci.py's wsjf(): the Operator's business value where + it gave one, else the median; None while no estimate carries a + complexity. """ - if not estimates: + complexity = points(estimates) + if complexity is None: return None - value = statistics.median(entry["value"] for entry in estimates.values()) - complexity = statistics.median(entry["complexity"] for entry in estimates.values()) + value = business_value(estimates) return {"value": value, "complexity": complexity, "score": round(value / complexity, 2)} @@ -96,6 +117,11 @@ def located(row: dict, trees: list[dict], open_task: bool) -> dict: return {"worktree": tree["path"], "worktree_branch": tree["branch"], "repo": tree["repo"], "flow": tree["flow"], "open_work": [*own, *tree["open_work"]]} +def after_of(row: dict) -> list[str]: + raw = row.get("after") + return [other for other in raw[:AFTER_SHOWN] if isinstance(other, str) and len(other) <= TEXT_SHOWN] if isinstance(raw, list) else [] + + def task_row(task: str, row: dict, trees: list[dict]) -> dict: estimates = estimates_of(row) ended = status_of(row) in ENDED @@ -108,6 +134,12 @@ def task_row(task: str, row: dict, trees: list[dict]) -> dict: "prio": prio_of(row), "estimates": estimates, "wsjf": wsjf(estimates), + "value": business_value(estimates), + "points": points(estimates), + "start": iso_epoch(row.get("start")), + "end": iso_epoch(row.get("end")) if ended else None, + "after": after_of(row), + "base": shown(row.get("base"))[:TEXT_SHOWN], "roles": roles_of(row), "queue": None, **located(row, trees, not ended), @@ -134,6 +166,12 @@ def queue_row(entry: dict, created: float, trees: list[dict]) -> dict: "prio": None, "estimates": {}, "wsjf": None, + "value": None, + "points": None, + "start": (epoch(entry.get("granted")) or created) if entry.get("status") == "active" else None, + "end": None, + "after": [], + "base": "-", "roles": {}, "queue": {"kind": text["kind"], "parent": text["task"], "enqueued": created}, **located(entry, trees, False), @@ -165,3 +203,75 @@ def task_rows(tasks: dict[str, dict], queue: list[tuple[dict, float]], scan: dic *(queue_row(entry, created, trees) for entry, created in jobs), *(task_row(task, tasks[task], trees) for task in ended[:ENDED_SHOWN]), ] + + +def register_ranks(tasks: dict[str, dict], agents: list[str]) -> dict[str, int]: + """Returns: + Each open task's index in rasci.py's ranked(): pin, WSJF, the + worker's place in the operator's agent order, then the name. + """ + order = {name: index for index, name in enumerate(agents)} + + def key(task: str) -> tuple: + worker = tasks[task].get("worker") + return (*rank(task, tasks[task])[:4], order.get(worker, len(order)) if isinstance(worker, str) else len(order), task) + + current = sorted((task for task, row in tasks.items() if status_of(row) not in ENDED), key=key) + return {task: index for index, task in enumerate(current)} + + +def queue_chain(queue: list[tuple[dict, float]], tasks: dict[str, dict], agents: list[str], now: float) -> list[tuple[str, bool]]: + """Args: + queue: the kvb-org-queue entries with their enqueue time. + tasks: the register rows by task. + agents: the operator's agent order. + now: epoch seconds. + + Returns: + The entry ids in grant order, each with whether it waits: the + active ones by grant time, then the waiting ones as queue.py's + ordered() sorts them (hotfix first, then the register rank less one + per whole hour waited, then the holder's agent order, then the + enqueue sequence). + """ + ranks = register_ranks(tasks, agents) + order = {name: index for index, name in enumerate(agents)} + + def waiting_key(pair: tuple[dict, float]) -> tuple: + entry, created = pair + task, name, seq = entry.get("task"), entry.get("name"), entry.get("seq") + hours = int((now - created) // HOUR) + place = ranks[task] if isinstance(task, str) and task in ranks else len(ranks) + holder = order[name] if isinstance(name, str) and name in order else len(order) + return (not entry.get("hotfix"), place - hours, holder, seq if isinstance(seq, int) and not isinstance(seq, bool) else 0) + + active = sorted((pair for pair in queue if pair[0].get("status") == "active"), key=lambda pair: epoch(pair[0].get("granted")) or pair[1]) + waiting = sorted((pair for pair in queue if pair[0].get("status") == "waiting"), key=waiting_key) + return [(shown(entry.get("id"))[:TEXT_SHOWN], entry.get("status") == "waiting") for entry, _ in [*active, *waiting]] + + +def branch_of(row: dict) -> str: + return row["branch"] if row["branch"] != "-" else row["worktree_branch"] + + +def task_edges(rows: list[dict], chain: list[tuple[str, bool]]) -> list[dict]: + """Args: + rows: the shown task rows of task_rows(). + chain: queue_chain(). + + Returns: + One {"from", "to", "kind"} edge per predecessor among the shown + rows: kind after for an explicit `rasci.py set --after`, base for a + task whose base branch is another task's branch, queue for each + waiting queue job and the job ahead of it. + """ + shown_tasks = {row["task"] for row in rows} + edges = [{"from": other, "to": row["task"], "kind": "after"} for row in rows for other in row["after"] if other in shown_tasks and other != row["task"]] + branches: dict[str, list[str]] = {} + for row in rows: + if row["kind"] != "queue" and branch_of(row): + branches.setdefault(branch_of(row), []).append(row["task"]) + edges += [{"from": other, "to": row["task"], "kind": "base"} for row in rows if row["kind"] != "queue" for other in branches.get(row["base"], []) if other != row["task"]] + jobs = [(f"{QUEUE}{job}", waits) for job, waits in chain] + edges += [{"from": ahead, "to": job, "kind": "queue"} for (ahead, _), (job, waits) in itertools.pairwise(jobs) if waits and {ahead, job} <= shown_tasks] + return edges diff --git a/orchestrator/kvb_orchestrator/timeline.py b/orchestrator/kvb_orchestrator/timeline.py index acc1e85..0724461 100644 --- a/orchestrator/kvb_orchestrator/timeline.py +++ b/orchestrator/kvb_orchestrator/timeline.py @@ -53,7 +53,7 @@ class Timeline: rows = [] for index, row in enumerate(radio_rows(radio, begin, end)): alarm = row["class"] in ("mayday", "pan-pan") and row["ts"] >= end - MAYDAY_ACTIVE - title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'alle'}" + title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'all'}" source = directory.key(row["from"], row["ts"]) who = {"agent": directory.at(source, row["ts"]) or row["from"], "now": directory.now(source), "session": source} ref = {"to": row["to"], "class": row["class"], "targets": [directory.key(name, row["ts"]) for name in row["to"]]} diff --git a/orchestrator/kvb_orchestrator/tree.py b/orchestrator/kvb_orchestrator/tree.py index 628d205..648c8d8 100644 --- a/orchestrator/kvb_orchestrator/tree.py +++ b/orchestrator/kvb_orchestrator/tree.py @@ -73,19 +73,19 @@ def dry_run(item: dict) -> list[str]: always stays. """ tip = (item["local_sha"] or item["head"] or "")[:12] - merged = f", gemergt durch {item['merged_by']}" if item["merged_by"] else "" + merged = f", merged by {item['merged_by']}" if item["merged_by"] else "" lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"] for action in item["actions"]: if action == "worktree": - lines.append(f"entfernt den Worktree {item['worktree']}") + lines.append(f"removes the worktree {item['worktree']}") elif action == "local-branch": - lines.append(f"löscht den lokalen Branch {item['branch']}") + lines.append(f"deletes the local branch {item['branch']}") elif action.startswith("remote"): - lines.append(f"bleibt: {action} (nur lokal)") + lines.append(f"stays: {action} (local only)") else: lines.append(action) - lines += [f"stoppt den Compose-Stack {name} ohne -v" for name in item["compose_projects"]] - lines += [f"entfernt das Image {name}" for name in item["images"]] + lines += [f"stops the Compose stack {name} without -v" for name in item["compose_projects"]] + lines += [f"removes the image {name}" for name in item["images"]] return lines @@ -191,7 +191,10 @@ class Tree: try: info = self.file.stat() except OSError as error: - return {"repos": [], "scanned": None, "error": f"no pkgmgr tree file at {self.file}: {error.strerror}"} + return {"repos": [], "scanned": None, "error": ( + f"no pkgmgr tree file at {self.file}: {error.strerror}. The tree timer runs `pkgmgr branch tree`, which the installed pkgmgr " + "lacks until a release ships it: release pkgmgr and install it, then the timer writes the file within a minute." + )} stamp = (info.st_mtime_ns, info.st_size) if self.cached is None or stamp != self.stamp: self.cached, self.stamp = read_tree(self.file), stamp diff --git a/orchestrator/kvb_orchestrator/view.py b/orchestrator/kvb_orchestrator/view.py index cd2ff70..5637fea 100644 --- a/orchestrator/kvb_orchestrator/view.py +++ b/orchestrator/kvb_orchestrator/view.py @@ -36,7 +36,7 @@ from .reviewing import review_row from .settings import media_origins, station_origins, valid_settings from .signed import signing from .skill_scripts import OUTPUT_LIMIT -from .tasks import task_rows +from .tasks import queue_chain, task_edges, task_rows RADIO_WINDOW = 86400 JOBS_SHOWN = 20 @@ -103,6 +103,9 @@ class View: people = {node["id"]: {"name": node["label"], "now": directory.now(node["id"]) or node["label"], "names": node["names"]} for node in drawn["nodes"] if node["kind"] != "subagent"} settings = self.settings.get() inbox = self.inbox(now, tasks, directory) + queue = queue_entries(self.council) + order = stored_order(self.council / AGENT_ORDER) + rows = task_rows(tasks, queue, self.tree.get()) return { "settings": settings, "media": media_origins(settings), @@ -128,8 +131,9 @@ class View: "messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end], "operator": operator_rows(tasks), "register": register_rows(tasks), - "tasks": task_rows(tasks, queue_entries(self.council), self.tree.get()), - "agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)), + "tasks": rows, + "task_edges": task_edges(rows, queue_chain(queue, tasks, order, now)), + "agent_order": agent_order(tasks, claims, order), "radio": radio, "claims": claims, "grants": grant_rows, diff --git a/orchestrator/server.py b/orchestrator/server.py index dfc4483..7030b87 100755 --- a/orchestrator/server.py +++ b/orchestrator/server.py @@ -18,7 +18,7 @@ --host-state: the state directory's path on the host, named in the radio rows that carry an upload or a voice recording. --skills: installed skills directory holding the kvb-org-radio, - kvb-org-rasci and kvb-org-sunset scripts. + kvb-org-rasci and kvb-org-suspend scripts. --transcripts: directory the Claude Code transcripts (*.jsonl) lie under. --templates: directory holding index.html.j2 and the *.js modules it includes. diff --git a/orchestrator/templates/faces.js b/orchestrator/templates/faces.js new file mode 100644 index 0000000..0914d2e --- /dev/null +++ b/orchestrator/templates/faces.js @@ -0,0 +1,95 @@ +const Faces = (() => { + "use strict"; + const SIZE = 40; + const INK = "#2b2622"; + const dot = (cx, cy, rx, ry, fill) => ["ellipse", {cx, cy, rx, ry, fill}]; + const line = (d, width) => ["path", {d, fill: "none", stroke: INK, "stroke-width": width, "stroke-linecap": "round", "stroke-linejoin": "round"}]; + const blob = (d, fill) => ["path", {d, fill}]; + const sclera = (cx) => ["ellipse", {cx, cy: 20, rx: 3.2, ry: 3, fill: "#fff", stroke: INK, "stroke-width": 0.8}]; + const cheek = (cx) => ["ellipse", {cx, cy: 25, rx: 2.4, ry: 1.5, fill: "#f08a8a", opacity: 0.6}]; + const SHAPES = [ + ["ellipse", {cx: 20, cy: 22, rx: 13, ry: 13}], + ["ellipse", {cx: 20, cy: 23, rx: 11, ry: 14}], + ["rect", {x: 7, y: 9, width: 26, height: 26, rx: 8}], + ["ellipse", {cx: 20, cy: 22, rx: 15, ry: 12}], + ]; + const COLOURS = ["#f6d5b8", "#e3ac85", "#b97a55", "#7d4f33", "#f4cf5d", "#9fd4c7", "#c2b2f2"]; + const EYES = [ + [dot(15, 20, 1.8, 1.8, INK), dot(25, 20, 1.8, 1.8, INK)], + [dot(15, 20, 1.4, 2.5, INK), dot(25, 20, 1.4, 2.5, INK)], + [line("M12.5 21q2.5-3 5 0M22.5 21q2.5-3 5 0", 1.6)], + [line("M12.5 20.5h5M22.5 20.5h5", 1.6)], + [sclera(15), sclera(25), dot(15.6, 20.4, 1.4, 1.4, INK), dot(25.6, 20.4, 1.4, 1.4, INK)], + ]; + const BROWS = [ + [], + [line("M12 15.5h6M22 15.5h6", 1.4)], + [line("M12 16q3-2.5 6 0M22 16q3-2.5 6 0", 1.4)], + [line("M12 14.5l6 1.5M28 14.5l-6 1.5", 1.4)], + ]; + const MOUTHS = [ + blob("M14 27q6 6 12 0z", INK), + dot(20, 28, 2.2, 2, INK), + blob("M13 26q7 7 14 0q-7 2-14 0z", INK), + ["rect", {x: 15, y: 27.2, width: 10, height: 1.8, rx: 0.9, fill: INK}], + blob("M15 28q5 3 10-2q-5 4.5-10 2z", INK), + ]; + const HAIR = [ + [], + [blob("M7 19q0-12 13-12t13 12q-5-6-13-6t-13 6z", "#3b2a20")], + [blob("M9 15l2-8 3 5 3-7 3 6 3-6 3 7 3-5 2 8q-11-4-22 0z", "#d9a441")], + [blob("M8 14q12-14 24 0z", "#3f6fb5"), ["rect", {x: 17, y: 12.5, width: 19, height: 2.6, rx: 1.3, fill: "#2f568f"}]], + [blob("M8 13q12-17 24 0z", "#c0504d"), ["rect", {x: 7, y: 11.5, width: 26, height: 3, rx: 1.5, fill: "#9c3d3a"}], dot(20, 4.6, 2.6, 2.6, "#f1f1f1")], + [dot(20, 6.5, 4.5, 4, "#6b3f2a"), blob("M8 18q1-11 12-11t12 11q-4-5-12-5t-12 5z", "#6b3f2a")], + ]; + const EXTRAS = [ + [], + [["path", {d: "M11 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M21 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M19 20h2", fill: "none", stroke: INK, "stroke-width": 1.1}]], + [cheek(12.5), cheek(27.5)], + [["rect", {x: 4.5, y: 18, width: 3.5, height: 8, rx: 1.75, fill: INK}], line("M6.5 25.5q1 5 7.5 4.5", 1.3)], + [12.5, 14.5, 12, 27.5, 25.5, 28].map((cx, index) => dot(cx, 24 + (index % 3) * 1.1, 0.75, 0.75, "#8a5a3c")), + ]; + const ATTRIBUTES = {shape: SHAPES, colour: COLOURS, eyes: EYES, brows: BROWS, mouth: MOUTHS, hair: HAIR, accessory: EXTRAS}; + const COUNTS = Object.fromEntries(Object.entries(ATTRIBUTES).map(([name, list]) => [name, list.length])); + const COMBINATIONS = Object.values(COUNTS).reduce((product, count) => product * count, 1); + + function hash(text) { + let value = 2166136261; + for (const char of String(text)) { + value = Math.imul(value ^ char.codePointAt(0), 16777619); + } + value = Math.imul(value ^ (value >>> 16), 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return (value ^ (value >>> 16)) >>> 0; + } + + /** + * @param id - the agent's stable key: its session id, or name: for an agent without a session. + * @returns {pick, parts}: pick holds the variant index of every attribute in ATTRIBUTES order; parts lists the + * [tag, attributes] SVG children of a 0 0 SIZE SIZE view box from back to front, the mouth carrying class "mouth". + */ + function of(id) { + let rest = hash(id); + const pick = {}; + for (const [name, count] of Object.entries(COUNTS)) { + pick[name] = rest % count; + rest = Math.floor(rest / count); + } + const [tag, shape] = SHAPES[pick.shape]; + const [mouth, lips] = MOUTHS[pick.mouth]; + const parts = [ + [tag, {...shape, fill: COLOURS[pick.colour], stroke: INK, "stroke-width": 0.8, "stroke-opacity": 0.4}], + ...HAIR[pick.hair], + ...EYES[pick.eyes], + ...BROWS[pick.brows], + [mouth, {...lips, class: "mouth"}], + ...EXTRAS[pick.accessory], + ]; + return {pick, parts}; + } + + return {SIZE, COUNTS, COMBINATIONS, of}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = Faces; +} diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index bcc7b12..de073f7 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -1,5 +1,5 @@ - + @@ -28,7 +28,7 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text) button, input, select { font: inherit; color: inherit; } code, pre { font-family: var(--mono); font-size: var(--fs-sm); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); } -.top, .navbar, main, .drawer, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); } +.top, .navbar, main, .popup-box, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; } @@ -140,7 +140,7 @@ pre.notes { max-height: 260px; margin: 12px 0 0; padding: 10px 12px; overflow: a .chip.preset { font-weight: 650; } .chip .x { padding: 0 2px; border: 0; background: none; color: inherit; cursor: pointer; } .split { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; align-items: start; } -@media (max-width: 860px) { .split, .graph-body { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 860px) { .split { grid-template-columns: minmax(0, 1fr); } } .tree { position: sticky; top: 0; max-height: 70vh; padding: 10px 6px; overflow: auto; } .tree-title { margin: 6px 10px 4px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .tree-list { margin: 0; padding: 0 0 0 10px; list-style: none; } @@ -206,16 +206,17 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital .replay { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 280px; } .replay input[type="range"] { flex: 1; accent-color: var(--accent); } .window-label { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; } -.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; } .stage-card { padding: 8px; } .stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); } +body.fit { display: flex; flex-direction: column; height: 100vh; } +body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; } +body.fit main { padding-bottom: var(--sp-5); } +body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; } .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); } .legend span { display: inline-flex; align-items: center; gap: 6px; } .swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); } -.panel { position: sticky; top: calc(var(--head) + 12px); max-height: calc(100vh - var(--head) - 24px); overflow: auto; } -.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; } .panel-title { margin: 0; font-size: var(--fs-xl); font-weight: 650; overflow-wrap: anywhere; } -.panel h3, .drawer-body h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } +.popup-box h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .mini { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } .mini li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); overflow-wrap: anywhere; } .mini .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } @@ -240,11 +241,7 @@ svg .node.k-operator circle { fill: var(--accent); stroke: color-mix(in srgb, va svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; } svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); } svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; } -svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; } svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; } -svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; } -svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); } -svg .k-chair .initial { fill: var(--accent); font-size: var(--fs-lg); } svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; } svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); } svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; } @@ -270,7 +267,9 @@ svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); } svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); } svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); } svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); } -svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; } +svg .node.picked circle { fill: var(--alarm); stroke: var(--alarm); transform: scale(1.6); transform-box: fill-box; transform-origin: center; } +svg .lane-label.picked { fill: var(--alarm); font-size: var(--fs-lg); font-weight: 750; } +.linkish.picked { color: var(--alarm); transform: scale(1.15); } svg .edge.merge { stroke-dasharray: 5 5; } svg .lane-mark { stroke-width: 1.2; } svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); } @@ -279,15 +278,21 @@ svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); } svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); } input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); } .custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; } -.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); } +.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; overflow: hidden; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); } +.avatar.big { width: 52px; height: 52px; } +.avatar svg { display: block; width: 100%; height: 100%; } .linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; } .linkish:hover { color: var(--accent); text-decoration: underline; } -.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(480px, 100vw); background: var(--glass-card); border-left: 1px solid var(--line); box-shadow: var(--shadow-lift); } -.drawer[hidden] { display: none; } -.drawer-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); } -.drawer-body { overflow: auto; padding: 4px 18px 12px; } -.drawer-form { display: grid; gap: 8px; padding: 12px 18px 18px; border-top: 1px solid var(--line); } +.popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); } +.fade { animation: fade .2s var(--ease); } +.leader { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.leader line { stroke: var(--alarm); stroke-width: 2; stroke-linecap: round; } +.popup-box { position: absolute; display: flex; flex-direction: column; gap: var(--sp-2); width: min(440px, calc(100% - 2 * var(--sp-4))); max-height: calc(100% - 2 * var(--sp-4)); padding: var(--sp-4); overflow: auto; background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); opacity: .94; } +.popup-head { display: flex; flex: none; align-items: center; gap: 12px; } +.popup-body { flex: 0 1 auto; min-height: 7em; overflow: auto; } +.popup-form { display: grid; flex: none; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); } +.node-search { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.node-search .chips { margin-bottom: 0; } textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; resize: vertical; } .bubble { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 11px; border-radius: 12px; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; } .bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; } @@ -371,8 +376,6 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v .stage canvas.space { display: block; width: 100%; height: clamp(360px, 68vh, 820px); border-radius: var(--radius-sm); cursor: grab; touch-action: none; } .stage canvas.space:active { cursor: grabbing; } .space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); } -.space-list { padding: 6px 10px 2px; } -.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; } body.sky-look .shell { visibility: hidden; } body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; } body.sky-look .sky:active { cursor: grabbing; } @@ -418,8 +421,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi

Agent Orchestrator

- - + +