diff --git a/README.md b/README.md index 4a58106..386fa32 100644 --- a/README.md +++ b/README.md @@ -405,17 +405,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 "Waiting for 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. -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; 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. 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 cd525db..0b6213a 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,12 +569,12 @@ 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). @@ -615,7 +614,7 @@ flowchart TD `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 +- [ ] 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) @@ -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 diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index f4d2869..260fd44 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)")), ) diff --git a/orchestrator/kvb_orchestrator/approving.py b/orchestrator/kvb_orchestrator/approving.py index 801617a..68ccf7e 100644 --- a/orchestrator/kvb_orchestrator/approving.py +++ b/orchestrator/kvb_orchestrator/approving.py @@ -81,7 +81,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 +94,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: @@ -112,7 +112,7 @@ class Approving: 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 or operator command needs the passkey signature") expect(proof, ("payload", "assertion")) try: payload = signing.checked_approval(proof["payload"], now) @@ -136,15 +136,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 +158,15 @@ 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}) 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,7 +216,7 @@ 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 return inbox_item(raw, label, title, record.get("gate", ""), subject, self.requester(raw, record["session"] if kind_name == "ask" else "")) @@ -259,5 +259,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/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/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/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/names.py b/orchestrator/kvb_orchestrator/names.py index 04531ba..43e1fbf 100644 --- a/orchestrator/kvb_orchestrator/names.py +++ b/orchestrator/kvb_orchestrator/names.py @@ -75,8 +75,8 @@ class Directory: 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 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/tasking.py b/orchestrator/kvb_orchestrator/tasking.py index ae31c70..b28c8a5 100644 --- a/orchestrator/kvb_orchestrator/tasking.py +++ b/orchestrator/kvb_orchestrator/tasking.py @@ -66,5 +66,5 @@ class Tasking: 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/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 a5c2d5d..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 diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index 91f65a7..b98aad5 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -1,5 +1,5 @@ - + @@ -426,8 +426,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi

Agent Orchestrator

- - + +