Merge master into fix/rename-release-changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # README.md
This commit is contained in:
commit
e94f2800d5
144 files changed
+4552
-1412
No files matched your search
@@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline
|
||||
|
||||
## Naming 🏷️
|
||||
|
||||
Every first-party skill is named `kvb-<category>-<name>` 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-<category>-<name>` 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:<port>/`, 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 "<Agent> 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 "<Agent> 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 <name>` 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 <name>` 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/<slug>`) and presidents (`work/<slug>`); 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 <office>` 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/<slug>`) and presidents (`work/<slug>`); 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 <office>` 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 <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, 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 <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, 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=<blob>` 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=<blob>` 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:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (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 <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, 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 <port> --timeout <seconds> --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/<id>/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:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (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 <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, 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 <port> --timeout <seconds> --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/<id>/choose`, which sends no answer, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks/<id>/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.
|
||||
|
||||
|
||||
@@ -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 <task> --after <other>` stores the dependency and
|
||||
- [x] `rasci.py set <task> --after <other>` stores the dependency and
|
||||
`--drop-after <other>` 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.
|
||||
@@ -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 <task> --prio <1-999>`,
|
||||
`estimate <task> Operator --value <fib> --complexity <fib>` or
|
||||
`estimate <task> Operator --drop`.
|
||||
`estimate <task> Operator --value <fib> --complexity <fib>`,
|
||||
`estimate <task> Operator --value <fib>` (the operator's business
|
||||
value alone) or `estimate <task> 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")
|
||||
|
||||
@@ -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"}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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']})"
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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']})"
|
||||
@@ -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:<name>`, `art:<kind or label>`
|
||||
and `seit:<span or date>`.
|
||||
now: the clock `seit:` spans count back from.
|
||||
text: the search line: words, `agent:<name>`, `kind:<kind or label>`
|
||||
and `since:<span or date>`.
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"]]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -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
|
||||
<header class="top">
|
||||
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
|
||||
<div class="tools">
|
||||
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Commands (Ctrl+K)" aria-label="Commands"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Settings and ambience" aria-label="Settings"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="navbar">
|
||||
@@ -433,11 +436,11 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<div class="shell">
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside>
|
||||
<dialog class="popup fade" id="popup" aria-label="Details"><svg class="leader" aria-hidden="true"><line></line></svg><div class="popup-box"></div></dialog>
|
||||
<div class="sheet" id="sheet" hidden></div>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
<div class="palette" id="palette" hidden></div>
|
||||
<div class="sky-panel" id="sky-panel" role="group" aria-label="Blick in den Himmel" hidden></div>
|
||||
<div class="sky-panel" id="sky-panel" role="group" aria-label="View of the sky" hidden></div>
|
||||
<script nonce="{{ nonce }}" id="state" type="application/json">{{ state | tojson }}</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{% include "ambience.js" %}
|
||||
@@ -446,6 +449,12 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "voice.js" %}
|
||||
|
||||
{% include "nav.js" %}
|
||||
|
||||
{% include "popup.js" %}
|
||||
|
||||
{% include "faces.js" %}
|
||||
|
||||
{% include "plan.js" %}
|
||||
</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||
@@ -518,6 +527,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-tasks-prio.js" %}
|
||||
|
||||
{% include "page-tasks-gantt.js" %}
|
||||
|
||||
{% include "page-choices.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
{% include "page-pulses.js" %}
|
||||
@@ -542,6 +555,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-keyboard.js" %}
|
||||
|
||||
{% include "page-popup.js" %}
|
||||
|
||||
{% include "page-agent.js" %}
|
||||
|
||||
{% include "page-palette.js" %}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const Nav = (() => {
|
||||
"use strict";
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]};
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const TASK_VIEWS = [["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
|
||||
const ICONS = {
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3",
|
||||
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", gantt: "bi-calendar-range", burn: "bi-graph-down-arrow",
|
||||
};
|
||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
}
|
||||
|
||||
function orderItem(name, index) {
|
||||
const up = officeBtn("bi-arrow-up", `${name} nach oben`, `${name} nach oben: höhere Priorität`, () => moveAgent(name, -1), `up:${name}`);
|
||||
const down = officeBtn("bi-arrow-down", `${name} nach unten`, `${name} nach unten: niedrigere Priorität`, () => moveAgent(name, 1), `down:${name}`);
|
||||
const up = officeBtn("bi-arrow-up", `Move ${name} up`, `Move ${name} up: higher priority`, () => moveAgent(name, -1), `up:${name}`);
|
||||
const down = officeBtn("bi-arrow-down", `Move ${name} down`, `Move ${name} down: lower priority`, () => moveAgent(name, 1), `down:${name}`);
|
||||
up.disabled = index === 0;
|
||||
down.disabled = index === ui.order.length - 1;
|
||||
const item = h("li", {draggable: "true", "data-name": name}, icon("bi-grip-vertical", "grip"), h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name), h("span", {class: "spacer"}), up, down);
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
async function saveAgentOrder() {
|
||||
if (await act("order", "Priorität", {agents: ui.order})) {
|
||||
if (await act("order", "Priority", {agents: ui.order})) {
|
||||
ui.order = null;
|
||||
drawAgentOrder();
|
||||
}
|
||||
@@ -85,14 +85,14 @@
|
||||
orderDom.focus = null;
|
||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.agent_order);
|
||||
box.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Priorität der Agenten", counter(ui.order.length))),
|
||||
h("p", {class: "muted"}, "Ordnet die Freigaben des Chairs und entscheidet nur bei gleicher Aufgabenpriorität; die --prio-Pins der Aufgaben bleiben unverändert."),
|
||||
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Reihenfolge der Agenten"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "Kein Agent mit Amt oder offener Aufgabe."),
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Agent priority", counter(ui.order.length))),
|
||||
h("p", {class: "muted"}, "Orders the chair's grants and decides only between equal task priorities; the tasks' --prio pins stay unchanged."),
|
||||
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Agent order"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "No agent with an office or an open task."),
|
||||
h("div", {class: "actions"},
|
||||
h("span", {class: "muted"}, "Ziehen oder Pfeile zum Umsortieren"),
|
||||
h("span", {class: "muted"}, "Drag or use the arrows to reorder"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Zurücksetzen"),
|
||||
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Reihenfolge speichern")));
|
||||
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Reset"),
|
||||
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Save order")));
|
||||
const target = key && [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
|
||||
if (target && target.disabled) {
|
||||
refocus(box, key.startsWith("up:") ? `down:${key.slice(3)}` : key.startsWith("down:") ? `up:${key.slice(5)}` : "order:title");
|
||||
|
||||
@@ -7,33 +7,35 @@
|
||||
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
|
||||
}
|
||||
|
||||
function drawDrawer() {
|
||||
if (!drawerDom) {
|
||||
function drawAgent() {
|
||||
if (!agentDom) {
|
||||
return;
|
||||
}
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
drawerDom.info.replaceChildren(
|
||||
h("h3", {}, "Aufgaben", counter(tasks.length)),
|
||||
agentDom.info.replaceChildren(
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
h("h3", {}, "Tasks", counter(tasks.length)),
|
||||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||||
h("div", {class: "row"}, h("code", {}, task.task), status(task.status)),
|
||||
h("span", {}, dash(task.title)),
|
||||
h("span", {class: "roles"}, [task.worker ? "Worker" : "", task.letters ? `Rollen ${task.letters}` : ""].filter(Boolean).join(" · "))))) : h("p", {class: "muted"}, "Keine offenen Aufgaben."),
|
||||
h("span", {class: "roles"}, [task.worker ? "Worker" : "", task.letters ? `Roles ${task.letters}` : ""].filter(Boolean).join(" · "))))) : h("p", {class: "muted"}, "No open tasks."),
|
||||
h("h3", {}, "Claims", counter(claims.length)),
|
||||
claims.length ? h("ul", {class: "mini"}, claims.map((claim) => h("li", {},
|
||||
h("div", {class: "row"}, h("code", {}, claim.office), claim.beat === null ? null : h("span", {class: "muted"}, "Puls ", when(claim.beat))),
|
||||
h("div", {class: "row"}, h("code", {}, claim.office), claim.beat === null ? null : h("span", {class: "muted"}, "Pulse ", when(claim.beat))),
|
||||
h("span", {}, dash(claim.work)),
|
||||
h("span", {class: "muted"}, `${dash(claim.runtime)} · seit ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "Keine Claims."));
|
||||
h("span", {class: "muted"}, `${dash(claim.runtime)} · since ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "No claims."));
|
||||
const rows = history(ui.agent);
|
||||
drawerDom.history.replaceChildren(rows.length
|
||||
agentDom.history.replaceChildren(rows.length
|
||||
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text, voiceReplay(row.voice))))
|
||||
: h("p", {class: "muted"}, "Kein Verlauf im gewählten Zeitraum."));
|
||||
: h("p", {class: "muted"}, "No history in the chosen time range."));
|
||||
placePopup();
|
||||
}
|
||||
|
||||
async function loadSubagents(key, holder, life) {
|
||||
if (!SESSION_ID.test(key)) {
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Keine Session bekannt."));
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "No session known."));
|
||||
return;
|
||||
}
|
||||
let data = null;
|
||||
@@ -44,52 +46,51 @@
|
||||
data = null;
|
||||
}
|
||||
if (!data) {
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Subagenten nicht lesbar."));
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Subagents unreadable."));
|
||||
return;
|
||||
}
|
||||
life.textContent = stamp(data);
|
||||
const rows = data.subagents.map((row) => ({...row, session: key})).sort((a, b) => a.started - b.started);
|
||||
holder.replaceChildren(rows.length ? h("ul", {class: "mini"}, rows.map((row) => {
|
||||
const item = h("li", {},
|
||||
h("div", {class: "row"}, h("button", {type: "button", class: "linkish", title: "Transkript im Stream zeigen", on: {click: () => focusSub(row.session, row.id)}}, `${row.type}: ${row.description}`), row.finished ? pill("fertig", "neutral") : pill("läuft", "ok")),
|
||||
h("span", {class: "muted"}, `Tiefe ${row.depth} · `, when(row.started), stamp(row) ? ` · ${stamp(row)}` : ""));
|
||||
h("div", {class: "row"}, h("button", {type: "button", class: "linkish", title: "Show transcript in the stream", on: {click: () => focusSub(row.session, row.id)}}, `${row.type}: ${row.description}`), row.finished ? pill("finished", "neutral") : pill("running", "ok")),
|
||||
h("span", {class: "muted"}, `Depth ${row.depth} · `, when(row.started), stamp(row) ? ` · ${stamp(row)}` : ""));
|
||||
item.style.marginLeft = `${(row.depth - 1) * 18}px`;
|
||||
return item;
|
||||
})) : h("p", {class: "muted"}, "Keine Subagenten."));
|
||||
})) : h("p", {class: "muted"}, "No subagents."));
|
||||
placePopup();
|
||||
}
|
||||
|
||||
async function deliver(text, file, cls) {
|
||||
async function deliver(key, text, file, cls) {
|
||||
let upload = "";
|
||||
if (file) {
|
||||
if (file.size > UPLOAD_LIMIT) {
|
||||
toast("Datei zu groß", [`${file.name}: höchstens 50 MiB`], false);
|
||||
toast("File too large", [`${file.name}: at most 50 MiB`], false);
|
||||
return false;
|
||||
}
|
||||
const stored = await send(`/api/uploads?name=${encodeURIComponent(file.name)}`, file, "application/octet-stream");
|
||||
if (!stored.ok || !stored.data) {
|
||||
toast("Upload fehlgeschlagen", [`${stored.status} ${stored.text}`.trim()], false);
|
||||
toast("Upload failed", [`${stored.status} ${stored.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
upload = stored.data.id;
|
||||
}
|
||||
return message(nameOf(ui.agent), text, upload, cls);
|
||||
return message(nameOf(key), text, upload, cls);
|
||||
}
|
||||
|
||||
function message(to, text, upload, cls) {
|
||||
return radioSend({to, text, upload, cls, voice: ""});
|
||||
}
|
||||
|
||||
function openAgent(who) {
|
||||
const drawer = document.getElementById("drawer");
|
||||
function openAgent(who, anchor, closed) {
|
||||
const key = agentKey(who);
|
||||
const name = nameOf(key);
|
||||
ui.agent = key;
|
||||
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
|
||||
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
|
||||
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Senden")}, icon("bi-send"));
|
||||
const text = h("textarea", {"aria-label": `Message to ${name}`, placeholder: `Message to ${name} …`});
|
||||
const file = h("input", {type: "file", class: "field", "aria-label": "Attach file"});
|
||||
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Send")}, icon("bi-send"));
|
||||
const classes = choice("to-one", TO_ONE, "post");
|
||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; dein Passkey signiert, prüfbar mit operator_message."),
|
||||
const form = h("form", {class: "popup-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post arrives at the end of the turn, Call at once with a read receipt; your passkey signs, verifiable with operator_message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -97,35 +98,35 @@
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
if (await deliver(text.value, file.files[0] || null, picked(classes))) {
|
||||
if (await deliver(key, text.value, file.files[0] || null, picked(classes))) {
|
||||
text.value = "";
|
||||
file.value = "";
|
||||
}
|
||||
button.disabled = false;
|
||||
});
|
||||
const info = h("div", {});
|
||||
const subs = h("div", {}, h("p", {class: "muted"}, "Lade Subagenten …"));
|
||||
const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …"));
|
||||
const log = h("div", {"aria-live": "polite"});
|
||||
const life = h("span", {class: "muted"});
|
||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||
drawer.replaceChildren(
|
||||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeAgent)),
|
||||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
|
||||
form);
|
||||
drawer.hidden = false;
|
||||
drawerDom = {info, history: log};
|
||||
drawDrawer();
|
||||
openPopup(name, [
|
||||
popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
|
||||
h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||
form,
|
||||
], anchor, () => {
|
||||
agentDom = null;
|
||||
ui.agent = null;
|
||||
if (closed) {
|
||||
closed();
|
||||
}
|
||||
});
|
||||
ui.agent = key;
|
||||
agentDom = {info, history: log};
|
||||
drawAgent();
|
||||
loadSubagents(key, subs, life);
|
||||
text.focus();
|
||||
}
|
||||
|
||||
function closeAgent() {
|
||||
document.getElementById("drawer").hidden = true;
|
||||
drawerDom = null;
|
||||
ui.agent = null;
|
||||
}
|
||||
|
||||
function agentNames() {
|
||||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
return;
|
||||
}
|
||||
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
||||
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
|
||||
const labels = {granted: "Notifications on", denied: "Notifications blocked", default: "Enable notifications", unsupported: "Browser has no notifications"};
|
||||
relabel(button, "bi-bell", labels[permission] || labels.default);
|
||||
button.disabled = permission !== "default";
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
const newRadio = state.radio.filter((row) => !known.radio.has(`${row.ts}|${row.from}|${row.text.slice(0, 40)}`));
|
||||
const direct = newRadio.filter((row) => row.to.includes(HUMAN));
|
||||
const present = {mayday: newAlarms.length > 0, review: fresh.some((item) => item.kind !== "operator"), direct: direct.length > 0, radio: newRadio.length > 0};
|
||||
const kind = SOUND_KINDS.map(([id]) => id).find((id) => present[id] && settings.sounds[id].on);
|
||||
const kind = Voice.beep(present, settings, SOUND_KINDS.map(([id]) => id));
|
||||
if (kind) {
|
||||
play(kind, loudness(kind));
|
||||
}
|
||||
@@ -55,13 +55,13 @@
|
||||
window.setTimeout(() => speakRows(newRadio), lead * 1000);
|
||||
if (settings.notify && "Notification" in window && Notification.permission === "granted") {
|
||||
for (const row of newAlarms) {
|
||||
new Notification(`${row.class === "mayday" ? "Mayday" : "Pan-Pan"} von ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `alarm-${row.ts}`, requireInteraction: row.class === "mayday"});
|
||||
new Notification(`${row.class === "mayday" ? "Mayday" : "Pan-Pan"} from ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `alarm-${row.ts}`, requireInteraction: row.class === "mayday"});
|
||||
}
|
||||
for (const item of fresh) {
|
||||
new Notification(`${ITEM_LABELS[item.kind]} wartet`, {body: item.title.slice(0, 200), tag: `item-${keyOf(item)}`, requireInteraction: item.kind === "ask"});
|
||||
new Notification(`${ITEM_LABELS[item.kind]} waiting`, {body: item.title.slice(0, 200), tag: `item-${keyOf(item)}`, requireInteraction: item.kind === "ask"});
|
||||
}
|
||||
for (const row of direct) {
|
||||
new Notification(`Funk von ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `direct-${row.ts}`});
|
||||
new Notification(`Radio from ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `direct-${row.ts}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@
|
||||
for (const batch of state.pending) {
|
||||
if (!known.batches.has(batch.id)) {
|
||||
known.batches.add(batch.id);
|
||||
const source = batch.source.startsWith("rule:") ? "Regel" : "Freigabe";
|
||||
const source = batch.source.startsWith("rule:") ? "Rule" : "Grant";
|
||||
undoToast(batch, `${source}: ${batch.items.map((item) => short(item.title)).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const art = {repo: "", node: null};
|
||||
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artefakt", "bi-box-seam"], task: ["Aufgabe", "bi-card-checklist"]};
|
||||
const art = {repo: ""};
|
||||
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artifact", "bi-box-seam"], task: ["Task", "bi-card-checklist"]};
|
||||
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
|
||||
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
|
||||
const tail = (path, parts) => String(path).split("/").filter(Boolean).slice(-parts).join("/");
|
||||
@@ -71,16 +71,23 @@
|
||||
return found;
|
||||
}
|
||||
|
||||
function artSelect(id) {
|
||||
art.node = art.node === id ? null : id;
|
||||
drawArtifacts();
|
||||
function artSelect(id, anchor, closed) {
|
||||
const node = tree.model && tree.model.byId.get(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node.kind === "agent") {
|
||||
openAgent(node.data.name, anchor || pickOf(id), closed);
|
||||
} else {
|
||||
openPopup(node.label, artPopup(node), anchor || pickOf(id), closed);
|
||||
}
|
||||
}
|
||||
|
||||
function artMark(node, at, radius, extra, caption) {
|
||||
const [kindName] = ART_KINDS[node.kind];
|
||||
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" ");
|
||||
const classes = ["node", `a-${node.kind}`, extra].filter(Boolean).join(" ");
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`,
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`, "data-pick": node.id,
|
||||
on: {
|
||||
click: () => artSelect(node.id),
|
||||
keydown: (event) => {
|
||||
@@ -101,7 +108,7 @@
|
||||
const toneOf = (branch) => (branch.state === "merged-clean" ? "ok" : branch.state === "dirty" ? "warn" : branch.state === "protected" ? "violet" : branch.state === "stale" ? "warn" : branch.gone ? "bad" : "info");
|
||||
|
||||
function artOrbit(repo, model) {
|
||||
const svg = chartFrame(420, `Orbit: ${repo.name} als Sonne, Branches als Planeten (Größe = Commits voraus), Worktrees als Monde, Agenten als Satelliten`);
|
||||
const svg = chartFrame(420, `Orbit: ${repo.name} as the sun, branches as planets (size = commits ahead), worktrees as moons, agents as satellites`);
|
||||
const rings = repo.branches.length > 16 ? [200, 310] : [250];
|
||||
svg.append(...rings.map((r) => s("circle", {class: "ring", r})));
|
||||
const layer = s("g", {});
|
||||
@@ -132,14 +139,14 @@
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(branches.length, 1);
|
||||
return [branch.name, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
|
||||
}));
|
||||
const svg = chartFrame(440, `Chord: Überlappung der Branches von ${repo.name}; Band = gemeinsam geänderte Dateien, gestrichelt = Merge`);
|
||||
const svg = chartFrame(440, `Chord: branch overlap in ${repo.name}; band = files changed by both, dashed = merge`);
|
||||
const arcs = s("g", {});
|
||||
for (const overlap of repo.overlaps) {
|
||||
const from = spots.get(overlap.a);
|
||||
const to = spots.get(overlap.b);
|
||||
if (from && to) {
|
||||
arcs.append(s("path", {class: "edge t-alarm", d: `M${fixed(from.x)},${fixed(from.y)} Q0,0 ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": fixed(width(overlap.count))},
|
||||
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} gemeinsame Dateien (Konfliktrisiko)\n${overlap.files.join("\n")}`)));
|
||||
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} shared files (conflict risk)\n${overlap.files.join("\n")}`)));
|
||||
}
|
||||
}
|
||||
for (const merge of repo.merges) {
|
||||
@@ -148,7 +155,7 @@
|
||||
if (from && to && from !== to) {
|
||||
const control = {x: (from.x + to.x) * 0.3, y: (from.y + to.y) * 0.3};
|
||||
arcs.append(s("path", {class: "edge t-grant merge", d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-grant)"},
|
||||
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
|
||||
s("title", {}, `${merge.from} → ${merge.into}: merged by ${merge.by}`)));
|
||||
}
|
||||
}
|
||||
for (const edge of flowEdges(repo)) {
|
||||
@@ -156,7 +163,7 @@
|
||||
const to = spots.get(edge.into);
|
||||
const bend = edge.side === "pull" ? 0.55 : 0.15;
|
||||
arcs.append(s("path", {class: "edge t-direct flow", "data-side": edge.side, d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed((from.x + to.x) * bend)},${fixed((from.y + to.y) * bend)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-direct)"},
|
||||
s("title", {}, `${edge.from} → ${edge.into}: Merge-Flow, ${edge.side === "pull" ? `${edge.into} zieht ${edge.ref} vor dem Gate` : `${edge.from} mergt fertig in ${edge.ref}`}`)));
|
||||
s("title", {}, `${edge.from} → ${edge.into}: merge flow, ${edge.side === "pull" ? `${edge.into} pulls ${edge.ref} before the gate` : `${edge.from} merges into ${edge.ref} when done`}`)));
|
||||
}
|
||||
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
|
||||
const labels = branches.map((branch) => {
|
||||
@@ -175,10 +182,10 @@
|
||||
const [left, right, top, lane, full] = [200, 24, 40, 34, 1120];
|
||||
const height = top + lanes.length * lane + 12;
|
||||
const x = (ts) => left + ((Math.min(Math.max(ts, begin), end) - begin) / (end - begin)) * (full - left - right);
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: Commits, Reviews, Gates und Pushes je Branch von ${repo.name}`}, defs());
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: commits, reviews, gates and pushes per branch of ${repo.name}`}, defs());
|
||||
lanes.forEach((branch, index) => {
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "data-pick": `branch:${repo.path}\u0000${branch.name}`, on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
@@ -201,74 +208,62 @@
|
||||
|
||||
function artLegend(view) {
|
||||
const words = {
|
||||
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
|
||||
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation · gepunktet = Merge-Flow des Worktrees",
|
||||
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
|
||||
orbit: "Sun = repository · planet = branch, size = commits ahead · moon = worktree · satellite = agent or artifact",
|
||||
chord: "Band = files both branches change (from the reviews in the hub, known only there) · dashed = merge per gc classification · dotted = merge flow of the worktree",
|
||||
swimlane: "Commit = approved review · Gate = review gate and queue runs · Push = runner command with push · Time range = 24 h ▾",
|
||||
};
|
||||
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
|
||||
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
|
||||
}
|
||||
|
||||
function artPanel(model) {
|
||||
const node = model.byId.get(art.node);
|
||||
if (!node) {
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf ein Repository, einen Branch, einen Worktree, einen Agenten oder ein Artefakt.")];
|
||||
}
|
||||
function artPopup(node) {
|
||||
const [kindName, kindIcon] = ART_KINDS[node.kind];
|
||||
const data = node.data;
|
||||
const body = [];
|
||||
if (node.kind === "repo") {
|
||||
body.push(meta([["Pfad", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["Stand", data.scanned_at]]),
|
||||
body.push(meta([["Path", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["As of", data.scanned_at]]),
|
||||
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
|
||||
} else if (node.kind === "branch") {
|
||||
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
|
||||
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]),
|
||||
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
|
||||
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
|
||||
h("h3", {}, "Dry run"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
|
||||
} else if (node.kind === "worktree") {
|
||||
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Path", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||
flowDetails(data.flow, data.branch),
|
||||
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
|
||||
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "No agent works here."),
|
||||
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
|
||||
} else if (node.kind === "agent") {
|
||||
body.push(h("div", {class: "actions"}, btn("Agent öffnen", "btn small soft", () => openAgent(data.name))));
|
||||
} else if (node.kind === "artefact") {
|
||||
body.push(meta([["Ref", data.ref], ["Art", data.kind], ["Aufgabe", data.task], ["Seit", data.added]]), data.ended ? pill("Aufgabe beendet: aufräumen", "warn") : null);
|
||||
body.push(meta([["Ref", data.ref], ["Kind", data.kind], ["Task", data.task], ["Since", data.added]]), data.ended ? pill("Task ended: clean up", "warn") : null);
|
||||
} else {
|
||||
body.push(meta([["Titel", data.title], ["Status", data.status]]));
|
||||
body.push(meta([["Title", data.title], ["Status", data.status]]));
|
||||
}
|
||||
return [
|
||||
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => artSelect(node.id))),
|
||||
...body,
|
||||
];
|
||||
return [popupHead(icon(kindIcon), node.label, h("span", {class: "muted"}, kindName)), h("div", {class: "popup-body"}, body)];
|
||||
}
|
||||
|
||||
function artifactsScreen() {
|
||||
const view = ui.views.artifacts;
|
||||
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
|
||||
art.repo = event.target.value;
|
||||
art.node = null;
|
||||
drawArtifacts();
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const reload = iconBtn("bi-arrow-clockwise", "Neu laden", "btn small soft", fetchTree, "Neu laden: den Tree-Stand von pkgmgr lesen");
|
||||
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchTree, "Reload: read the tree state from pkgmgr");
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const legendBox = h("div", {});
|
||||
const list = h("div", {class: "repos"});
|
||||
const search = view === "tree" ? h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
|
||||
const search = view === "tree" ? h("input", {type: "search", placeholder: "Filter repo, branch, path or agent …", value: tree.q, "aria-label": "Filter tree", on: {input: (event) => {
|
||||
tree.q = event.target.value;
|
||||
drawArtifacts();
|
||||
}}}) : null;
|
||||
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
|
||||
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll};
|
||||
tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll};
|
||||
window.setTimeout(() => {
|
||||
drawArtifacts();
|
||||
fetchTree();
|
||||
}, 0);
|
||||
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
|
||||
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
|
||||
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside);
|
||||
const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox);
|
||||
return h("div", {class: "graph"}, controls, body);
|
||||
}
|
||||
|
||||
@@ -283,11 +278,11 @@
|
||||
tree.data = await response.json();
|
||||
tree.model = artifactModel(tree.data);
|
||||
} else {
|
||||
tree.data = {repos: [], scanned: null, error: `/api/tree antwortet ${response.status}`};
|
||||
tree.data = {repos: [], scanned: null, error: `/api/tree answered ${response.status}`};
|
||||
}
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
tree.data = tree.data || {repos: [], scanned: null, error: "Hub nicht erreichbar"};
|
||||
tree.data = tree.data || {repos: [], scanned: null, error: "Hub unreachable"};
|
||||
}
|
||||
tree.busy = false;
|
||||
drawArtifacts();
|
||||
@@ -302,17 +297,12 @@
|
||||
return;
|
||||
}
|
||||
const empty = !tree.data || tree.data.error;
|
||||
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
if (empty) {
|
||||
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
|
||||
tree.dom.aside.replaceChildren();
|
||||
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
|
||||
return;
|
||||
}
|
||||
const model = tree.model;
|
||||
if (art.node !== null && !model.byId.has(art.node)) {
|
||||
art.node = null;
|
||||
}
|
||||
tree.dom.aside.replaceChildren(...artPanel(model));
|
||||
if (tree.dom.view === "3d") {
|
||||
stage3d("artifacts", tree.dom.stage);
|
||||
return;
|
||||
@@ -320,5 +310,6 @@
|
||||
const repo = chosenRepo();
|
||||
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
|
||||
tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
|
||||
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("Kein Repository", "pkgmgr nennt kein Repository."));
|
||||
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("No repository", "pkgmgr names no repository."));
|
||||
placePopup();
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
const result = await sendJson(`/api/board/${action}`, payload);
|
||||
ui.busy = false;
|
||||
if (result.data && result.data.runs) {
|
||||
toast(`${label}: ${result.data.ok ? "erledigt" : "fehlgeschlagen"}`, runLines(result.data), result.data.ok);
|
||||
toast(`${label}: ${result.data.ok ? "done" : "failed"}`, runLines(result.data), result.data.ok);
|
||||
} else {
|
||||
toast(`${label}: fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||||
toast(`${label}: failed`, [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
return result.ok && result.data && result.data.ok;
|
||||
@@ -23,14 +23,14 @@
|
||||
}
|
||||
|
||||
function choice(name, options, picked) {
|
||||
return h("div", {class: "classes", role: "radiogroup", "aria-label": "Klasse"}, options.map(([id, label]) => h("label", {class: "check"},
|
||||
return h("div", {class: "classes", role: "radiogroup", "aria-label": "Class"}, options.map(([id, label]) => h("label", {class: "check"},
|
||||
h("input", {type: "radio", name, value: id, checked: id === picked}), label)));
|
||||
}
|
||||
|
||||
const picked = (group) => group.querySelector("input:checked").value;
|
||||
|
||||
function toAll() {
|
||||
const text = h("textarea", {"aria-label": "Funk an alle", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => {
|
||||
const text = h("textarea", {"aria-label": "Radio to all", placeholder: "Message to all agents …", on: {input: (event) => {
|
||||
ui.draft = event.target.value;
|
||||
}}});
|
||||
text.value = ui.draft;
|
||||
@@ -45,9 +45,9 @@
|
||||
text.value = "";
|
||||
}
|
||||
};
|
||||
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Funk an alle"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort. Dein Passkey signiert jede Nachricht."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Senden", "btn soft", send, "Senden an alle"))));
|
||||
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Radio to all"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter arrives as post at the end of the turn; Broadcast, Pan-Pan, Security and Mayday at once. Your passkey signs every message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Send", "btn soft", send, "Send to all"))));
|
||||
}
|
||||
|
||||
function openToAll() {
|
||||
@@ -62,12 +62,12 @@
|
||||
}
|
||||
|
||||
function agentsToolbar() {
|
||||
const clear = iconBtn("bi-life-preserver", "Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
|
||||
const clear = iconBtn("bi-life-preserver", "Cancel mayday", "btn small danger", () => confirmAct("clear-mayday"));
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("p", {class: "muted"}, "Drain: agents finish their task and take nothing new. Suspend: they park their work and idle. Resume: all continue from the handover. Your passkey signs each."),
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", openAgentOrder, "Priorität: Reihenfolge der Agenten in der Hierarchie"), night), clear, mode};
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, toAll(), clear, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night), clear, mode};
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
closeSheet();
|
||||
}
|
||||
});
|
||||
popupDialog().addEventListener("click", (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closePopup();
|
||||
}
|
||||
});
|
||||
document.getElementById("palette-open").addEventListener("click", openPalette);
|
||||
document.getElementById("settings-open").addEventListener("click", settingsSheet);
|
||||
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
|
||||
@@ -26,6 +31,7 @@
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("resize", redrawSky);
|
||||
window.addEventListener("resize", refresh3d);
|
||||
window.addEventListener("resize", placePopup);
|
||||
skyPanel();
|
||||
radio.element = radioElement();
|
||||
drawSky();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const CHOICE_ICONS = {post: "bi-envelope", call: "bi-telephone", newsletter: "bi-newspaper", broadcast: "bi-broadcast", "pan-pan": "bi-exclamation-triangle", security: "bi-shield-exclamation", mayday: "bi-life-preserver"};
|
||||
const choiceAsk = {to: new Set(), dom: null, names: ""};
|
||||
|
||||
async function requestChoices(cls) {
|
||||
const direct = TO_ONE.some(([id]) => id === cls);
|
||||
const to = direct ? [...choiceAsk.to].sort((a, b) => a.localeCompare(b)) : ["*"];
|
||||
if ((direct && !to.length) || (cls === "mayday" && !confirmMayday())) {
|
||||
return;
|
||||
}
|
||||
const label = `Choice request (${CLASSES[cls]}) to ${direct ? to.join(", ") : "all"}`;
|
||||
const signed = await signedProof("/api/choices/sign", {class: cls, to}, label);
|
||||
if (!signed) {
|
||||
return;
|
||||
}
|
||||
const result = await sendJson("/api/choices", {class: cls, to, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label} failed`, fail(result), false);
|
||||
return;
|
||||
}
|
||||
toast(`${label}: ${result.data.ok ? "sent" : "stored, sending failed"}`, [...result.data.ids.map((id) => `kvb-orchestrator msg ${id}`), ...runLines(result.data)], result.data.ok);
|
||||
await load();
|
||||
}
|
||||
|
||||
function choiceBar() {
|
||||
const picks = h("div", {class: "choice-agents", role: "group", "aria-label": "Post or call to"});
|
||||
const count = counter(0);
|
||||
const menu = h("details", {class: "menu"}, h("summary", named("Agents", "Agents: who gets a post or a call"), icon("bi-people"), count, caret()),
|
||||
h("div", {class: "menu-box"}, h("h3", {}, "Post or call to"), picks));
|
||||
const button = ([id, label]) => h("button", {type: "button", class: "btn small", "data-channel": id, ...named(label, `${label}: ask for every open decision as a choice, signed with your passkey. ${CHANNEL_HINTS[id]}`), on: {click: () => requestChoices(id)}},
|
||||
icon(CHOICE_ICONS[id]), label);
|
||||
const direct = TO_ONE.map(button);
|
||||
choiceAsk.dom = {picks, count, direct};
|
||||
choiceAsk.names = "";
|
||||
return h("div", {class: "card controls choice-request", role: "group", "aria-label": "Request choices"},
|
||||
h("span", {class: "group-title"}, "Request choices"), menu, direct, TO_ALL.map(button));
|
||||
}
|
||||
|
||||
function syncChoices() {
|
||||
choiceAsk.dom.count.textContent = String(choiceAsk.to.size);
|
||||
for (const button of choiceAsk.dom.direct) {
|
||||
button.disabled = !choiceAsk.to.size;
|
||||
}
|
||||
}
|
||||
|
||||
function pickChoice(name, on) {
|
||||
if (on) {
|
||||
choiceAsk.to.add(name);
|
||||
} else {
|
||||
choiceAsk.to.delete(name);
|
||||
}
|
||||
syncChoices();
|
||||
}
|
||||
|
||||
function drawChoices() {
|
||||
const dom = choiceAsk.dom;
|
||||
if (!dom || !dom.picks.isConnected) {
|
||||
return;
|
||||
}
|
||||
const names = agentNames();
|
||||
for (const name of [...choiceAsk.to]) {
|
||||
if (!names.includes(name)) {
|
||||
choiceAsk.to.delete(name);
|
||||
}
|
||||
}
|
||||
const sig = JSON.stringify(names);
|
||||
if (sig !== choiceAsk.names) {
|
||||
choiceAsk.names = sig;
|
||||
dom.picks.replaceChildren(...(names.length ? names.map((name) => h("label", {class: "check"},
|
||||
h("input", {type: "checkbox", value: name, checked: choiceAsk.to.has(name), on: {change: (event) => pickChoice(name, event.target.checked)}}), name)) : [h("p", {class: "muted"}, "No agent known yet.")]));
|
||||
}
|
||||
syncChoices();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const CLEANUP_POLL = 2000;
|
||||
const CLEANUP_PATIENCE = 900000;
|
||||
const RESULT_LABELS = {done: ["gelöscht", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||
const RESULT_LABELS = {done: ["deleted", "ok"], refused: ["refused", "bad"], failed: ["failed", "bad"]};
|
||||
|
||||
function planOf(item) {
|
||||
const repo = (tree.data.repos || []).find((entry) => entry.path === item.repository);
|
||||
@@ -19,37 +19,37 @@
|
||||
h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
|
||||
});
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger", ...named("Löschen", "Löschen: mit deiner Passkey-Signatur an den Host übergeben")}, icon("bi-trash3"), "Löschen");
|
||||
const confirm = h("button", {type: "button", class: "btn danger", ...named("Delete", "Delete: hand over to the host with your passkey signature")}, icon("bi-trash3"), "Delete");
|
||||
confirm.addEventListener("click", async () => {
|
||||
confirm.disabled = true;
|
||||
if (!await removeSigned(items, outcome)) {
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
openSheet(items.length > 1 ? `${items.length} gemergte Branches löschen` : `${items[0].branch} löschen`, [
|
||||
h("p", {class: "muted"}, "Trockenlauf von pkgmgr branch cleanup --local für genau diese Einträge: Worktree, lokaler Branch, Compose-Stack ohne -v und seine Images. Kein Remote-Branch. Der Host prüft deine Signatur und verweigert jeden Eintrag, dessen Stand sich seither geändert hat."),
|
||||
h("ul", {class: "mini", "aria-label": "Trockenlauf"}, rows),
|
||||
openSheet(items.length > 1 ? `Delete ${items.length} merged branches` : `Delete ${items[0].branch}`, [
|
||||
h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup --local for exactly these entries: worktree, local branch, Compose stack without -v and its images. No remote branch. The host checks your signature and refuses every entry whose state has changed since."),
|
||||
h("ul", {class: "mini", "aria-label": "Dry run"}, rows),
|
||||
outcome,
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
|
||||
], false);
|
||||
}
|
||||
|
||||
async function removeSigned(items, outcome) {
|
||||
const signed = await proofFor(items, "Löschen");
|
||||
const signed = await proofFor(items, "Delete");
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Not signed: nothing was handed over."));
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/cleanup", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast("Löschen: nicht möglich", fail(result), false);
|
||||
toast("Delete: not possible", fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signed and handed to the host (job ${result.data.id}); the path unit deletes now.`));
|
||||
followJob(`/api/cleanup/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||
const {good, lines} = showJob(data, outcome, "branch", RESULT_LABELS);
|
||||
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, lines, good);
|
||||
toast(`Delete: ${good ? "done" : "not everything deleted"}`, lines, good);
|
||||
fetchTree();
|
||||
});
|
||||
return true;
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
function showJob(data, outcome, key, labels) {
|
||||
const lines = data.error ? [data.error] : data.items.map((item) => `${item[key]}: ${(labels[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
||||
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Result"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||
const [label, tone] = labels[item.status] || [item.status, "neutral"];
|
||||
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item[key]), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
||||
})));
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
const CHANNELS = [...TO_ONE, ...TO_ALL];
|
||||
const CHANNEL_HINTS = {
|
||||
post: "Post kommt am Ende des Zuges an.",
|
||||
call: "Call kommt sofort, mit Lesebestätigung.",
|
||||
newsletter: "Newsletter kommt als Post an alle, am Ende ihres Zuges.",
|
||||
broadcast: "Broadcast erreicht alle sofort.",
|
||||
"pan-pan": "PAN-PAN: dringend, an alle, sofort.",
|
||||
security: "SECURITE: Sicherheitshinweis an alle, sofort.",
|
||||
mayday: "MAYDAY: alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst.",
|
||||
post: "Post arrives at the end of the turn.",
|
||||
call: "Call arrives at once, with a read receipt.",
|
||||
newsletter: "Newsletter arrives as post to all, at the end of their turn.",
|
||||
broadcast: "Broadcast reaches all at once.",
|
||||
"pan-pan": "PAN-PAN: urgent, to all, at once.",
|
||||
security: "Sécurité: safety notice to all stations, now.",
|
||||
mayday: "MAYDAY: all agents pause writing steps until you cancel it.",
|
||||
};
|
||||
const VOICE_POLL = 700;
|
||||
const composer = {voice: "", recorder: null, timer: 0, started: 0};
|
||||
|
||||
function confirmMayday() {
|
||||
return window.confirm("Mayday an alle senden? Alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst.");
|
||||
return window.confirm("Send a mayday to all? All agents pause writing steps until you cancel it.");
|
||||
}
|
||||
|
||||
async function radioSend(message) {
|
||||
const label = `${CLASSES[message.cls] || message.cls} an ${message.to === "*" ? "alle" : message.to}`;
|
||||
const label = `${CLASSES[message.cls] || message.cls} to ${message.to === "*" ? "all" : message.to}`;
|
||||
const fields = {to: message.to, text: message.text, upload: message.upload, class: message.cls, voice: message.voice};
|
||||
const signed = await signedProof("/api/messages/sign", fields, label);
|
||||
if (!signed) {
|
||||
@@ -27,10 +27,10 @@
|
||||
? await sendJson("/api/board/broadcast", {text: fields.text, class: fields.class, voice: fields.voice, proof})
|
||||
: await sendJson("/api/messages", {...fields, proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label} fehlgeschlagen`, fail(result), false);
|
||||
toast(`${label} failed`, fail(result), false);
|
||||
return false;
|
||||
}
|
||||
toast(`${label}: ${result.data.ok ? "gesendet" : "gespeichert, Senden fehlgeschlagen"}`, [...(result.data.id ? [`kvb-orchestrator msg ${result.data.id}`] : []), ...runLines(result.data)], result.data.ok);
|
||||
toast(`${label}: ${result.data.ok ? "sent" : "stored, sending failed"}`, [...(result.data.id ? [`kvb-orchestrator msg ${result.data.id}`] : []), ...runLines(result.data)], result.data.ok);
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
composer.voice = note;
|
||||
parts.attach.checked = false;
|
||||
parts.replay.hidden = !note;
|
||||
parts.replay.replaceChildren(...(note ? [icon("bi-soundwave"), h("span", {class: "muted"}, "Sprachnachricht"), voiceReplay(note), h("label", {class: "check"}, parts.attach, "Aufnahme anhängen"), iconBtn("bi-trash", "Aufnahme entfernen", "btn small ghost", () => attachVoice(parts, ""))] : []));
|
||||
parts.replay.replaceChildren(...(note ? [icon("bi-soundwave"), h("span", {class: "muted"}, "Voice message"), voiceReplay(note), h("label", {class: "check"}, parts.attach, "Attach recording"), iconBtn("bi-trash", "Remove recording", "btn small ghost", () => attachVoice(parts, ""))] : []));
|
||||
}
|
||||
|
||||
async function heardNote(note) {
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, VOICE_POLL));
|
||||
}
|
||||
return {status: "failed", error: "keine Mitschrift in der erwarteten Zeit"};
|
||||
return {status: "failed", error: "no transcript in the expected time"};
|
||||
}
|
||||
|
||||
async function keepRecording(blob, parts) {
|
||||
@@ -60,28 +60,28 @@
|
||||
try {
|
||||
const sound = context();
|
||||
if (!sound || !blob.size) {
|
||||
throw new Error("keine Aufnahme");
|
||||
throw new Error("no recording");
|
||||
}
|
||||
parts.status.textContent = "Wandelt um …";
|
||||
parts.status.textContent = "Converting …";
|
||||
const stored = await send("/api/voice", await wavOf(blob, sound), "audio/wav");
|
||||
if (!stored.ok || !stored.data) {
|
||||
throw new Error(`${stored.status} ${stored.text}`.trim());
|
||||
}
|
||||
attachVoice(parts, stored.data.id);
|
||||
parts.status.textContent = "Schreibt mit …";
|
||||
parts.status.textContent = "Transcribing …";
|
||||
const note = await heardNote(stored.data.id);
|
||||
if (note.status === "failed") {
|
||||
parts.status.textContent = "Ohne Mitschrift: schreib den Text selbst.";
|
||||
toast("Mitschrift fehlgeschlagen", [note.error], false);
|
||||
parts.status.textContent = "No transcript: write the text yourself.";
|
||||
toast("Transcript failed", [note.error], false);
|
||||
return;
|
||||
}
|
||||
parts.text.value = [parts.text.value.trim(), note.text].filter(Boolean).join(" ");
|
||||
parts.text.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
parts.text.focus();
|
||||
parts.status.textContent = note.text ? "Text prüfen, dann senden." : "Nichts verstanden: schreib den Text selbst.";
|
||||
parts.status.textContent = note.text ? "Check the text, then send." : "Nothing understood: write the text yourself.";
|
||||
} catch (error) {
|
||||
parts.status.textContent = "";
|
||||
toast("Aufnahme fehlgeschlagen", [String(error.message || error)], false);
|
||||
toast("Recording failed", [String(error.message || error)], false);
|
||||
} finally {
|
||||
parts.record.disabled = false;
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
function recordLabel(parts, on) {
|
||||
parts.record.setAttribute("aria-pressed", String(on));
|
||||
parts.record.replaceChildren(icon(on ? "bi-stop-fill" : "bi-record-circle"), on ? "Stopp" : "Aufnehmen");
|
||||
parts.record.replaceChildren(icon(on ? "bi-stop-fill" : "bi-record-circle"), on ? "Stop" : "Record");
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
@@ -103,7 +103,7 @@
|
||||
try {
|
||||
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
||||
} catch (error) {
|
||||
toast("Mikrofon nicht verfügbar", [String(error.message || error)], false);
|
||||
toast("Microphone unavailable", [String(error.message || error)], false);
|
||||
return;
|
||||
}
|
||||
const recorder = new MediaRecorder(media);
|
||||
@@ -125,7 +125,7 @@
|
||||
recordLabel(parts, true);
|
||||
composer.timer = window.setInterval(() => {
|
||||
const seconds = Math.floor((Date.now() - composer.started) / 1000);
|
||||
parts.status.textContent = `Nimmt auf ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} von höchstens ${state.speech.seconds} s`;
|
||||
parts.status.textContent = `Recording ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} of at most ${state.speech.seconds} s`;
|
||||
if (seconds >= state.speech.seconds) {
|
||||
stopRecording();
|
||||
}
|
||||
@@ -135,36 +135,36 @@
|
||||
function composerForm() {
|
||||
const channel = h("select", {class: "field"}, CHANNELS.map(([id, label]) => h("option", {value: id}, label)));
|
||||
const recipient = h("select", {class: "field"});
|
||||
const toField = h("label", {}, "An", recipient);
|
||||
const text = h("textarea", {"aria-label": "Nachricht", placeholder: "Nachricht an die Agenten …", maxlength: 20000});
|
||||
const toField = h("label", {}, "To", recipient);
|
||||
const text = h("textarea", {"aria-label": "Message", placeholder: "Message to the agents …", maxlength: 20000});
|
||||
const replay = h("div", {class: "voice-note", hidden: true});
|
||||
const hint = h("p", {class: "muted"});
|
||||
const status = h("span", {class: "muted composer-status", "aria-live": "polite"});
|
||||
const record = h("button", {type: "button", class: "btn small", "aria-pressed": "false", title: "Aufnehmen: ein Klick startet, noch einer beendet; der Hub schreibt mit, du prüfst den Text vor dem Senden"});
|
||||
const submit = h("button", {type: "submit", class: "btn primary"}, icon("bi-send"), "Senden");
|
||||
const attach = h("input", {type: "checkbox", title: "Aufnahme anhängen: die Agenten bekommen den Pfad der Tondatei; sonst geht nur der geprüfte Text"});
|
||||
const record = h("button", {type: "button", class: "btn small", "aria-pressed": "false", title: "Record: one click starts, another stops; the hub transcribes, you check the text before sending"});
|
||||
const submit = h("button", {type: "submit", class: "btn primary"}, icon("bi-send"), "Send");
|
||||
const attach = h("input", {type: "checkbox", title: "Attach recording: the agents get the path of the sound file; otherwise only the checked text goes out"});
|
||||
const parts = {record, status, text, replay, attach};
|
||||
recordLabel(parts, false);
|
||||
const gap = micGap();
|
||||
if (gap) {
|
||||
record.disabled = true;
|
||||
record.title = `Aufnehmen: ${gap}`;
|
||||
record.title = `Record: ${gap}`;
|
||||
}
|
||||
record.addEventListener("click", () => (composer.recorder ? stopRecording() : startRecording(parts)));
|
||||
const sync = () => {
|
||||
const direct = TO_ONE.some(([id]) => id === channel.value);
|
||||
toField.hidden = !direct;
|
||||
hint.textContent = `${CHANNEL_HINTS[channel.value]} Dein Passkey signiert jede Nachricht.`;
|
||||
hint.textContent = `${CHANNEL_HINTS[channel.value]} Your passkey signs every message.`;
|
||||
};
|
||||
channel.addEventListener("change", sync);
|
||||
sync();
|
||||
const form = h("form", {class: "composer"}, h("div", {class: "composer-grid"}, h("label", {}, "Kanal", channel), toField), text, replay, hint,
|
||||
const form = h("form", {class: "composer"}, h("div", {class: "composer-grid"}, h("label", {}, "Channel", channel), toField), text, replay, hint,
|
||||
h("div", {class: "actions"}, record, status, h("span", {class: "spacer"}), submit));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const direct = TO_ONE.some(([id]) => id === channel.value);
|
||||
if (!text.value.trim()) {
|
||||
toast("Nichts zu senden", ["Schreib einen Text oder nimm einen auf; die Mitschrift landet zum Prüfen im Feld."], false);
|
||||
toast("Nothing to send", ["Write a text or record one; the transcript lands in the field for checking."], false);
|
||||
return;
|
||||
}
|
||||
if ((direct && !recipient.value) || (channel.value === "mayday" && !confirmMayday())) {
|
||||
|
||||
@@ -25,19 +25,19 @@
|
||||
|
||||
function consoleWords(job) {
|
||||
if (job.status === "queued") {
|
||||
return `wartet auf den Runner seit ${lasting(nowSeconds() - job.created_ts)}`;
|
||||
return `waiting for the runner for ${lasting(nowSeconds() - job.created_ts)}`;
|
||||
}
|
||||
if (job.status === "running") {
|
||||
return `läuft seit ${lasting(nowSeconds() - job.started)}`;
|
||||
return `running for ${lasting(nowSeconds() - job.started)}`;
|
||||
}
|
||||
if (job.status === "refused") {
|
||||
return "der Runner hat die Signatur nicht angenommen";
|
||||
return "the runner did not accept the signature";
|
||||
}
|
||||
return `Exit-Code ${job.code} nach ${lasting(job.finished - job.started)}`;
|
||||
return `Exit code ${job.code} after ${lasting(job.finished - job.started)}`;
|
||||
}
|
||||
|
||||
function paintConsole(view, job) {
|
||||
const text = job.output || (jobOpen(job) ? "Noch keine Ausgabe." : "Keine Ausgabe.");
|
||||
const text = job.output || (jobOpen(job) ? "No output yet." : "No output.");
|
||||
if (view.out.textContent !== text) {
|
||||
view.out.textContent = text;
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
function consoleOf(job) {
|
||||
let view = consoles.get(job.id);
|
||||
if (!view) {
|
||||
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Ausgabe des Befehls", "data-job": job.id});
|
||||
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Command output", "data-job": job.id});
|
||||
const line = h("div", {class: "console-status"});
|
||||
view = {box: h("div", {class: "console", "data-job": job.id}, line, out), out, line, follow: true, top: 0};
|
||||
out.addEventListener("scroll", () => {
|
||||
@@ -87,7 +87,7 @@
|
||||
async function dismissJob(job) {
|
||||
const result = await sendJson("/api/inbox/dismiss", {id: job.id});
|
||||
if (!result.ok) {
|
||||
toast("Schließen nicht möglich", fail(result), false);
|
||||
toast("Cannot close", fail(result), false);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -3,27 +3,26 @@
|
||||
const ACTIVE_BEAT = 900;
|
||||
const DAY = 86400;
|
||||
const HUMAN = "Operator";
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]];
|
||||
const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]];
|
||||
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
||||
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
||||
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator", rule: "Regelwunsch"};
|
||||
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
|
||||
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Choice", operator: "Operator", rule: "Rule request"};
|
||||
const NIGHT = {day: ["Day", "ok"], drain: ["Draining", "warn"], suspend: ["Suspended", "violet"]};
|
||||
const ACTIONS = [["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?"], ["drain", "Drain", "Drain all agents: finish the current task, accept nothing new (kvb-agent-drain)?"], ["suspend", "Suspend", "Suspend all agents: park work, write the handover, idle (kvb-agent-suspend)?"], ["resume", "Resume", "Resume all agents from their handover (kvb-agent-resume)?"]];
|
||||
const UPLOAD_LIMIT = 50 * 1024 * 1024;
|
||||
const STREAM_KEEP = 5000;
|
||||
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Security", receipt: "Gelesen", grant: "Freigabe"};
|
||||
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sécurité", receipt: "Read", grant: "Grant"};
|
||||
const PILLS = {post: "info", call: "info", newsletter: "teal", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", receipt: "neutral", grant: "ok"};
|
||||
const LEGEND = [["direct", "Post / Call"], ["broadcast", "Newsletter / Broadcast"], ["alarm", "Mayday / Pan-Pan"], ["security", "Security"], ["grant", "Queue-Freigabe"]];
|
||||
const TO_ALL = [["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["pan-pan", "Pan-Pan"], ["security", "Security"], ["mayday", "Mayday"]];
|
||||
const LEGEND = [["direct", "Post / Call"], ["broadcast", "Newsletter / Broadcast"], ["alarm", "Mayday / Pan-Pan"], ["security", "Sécurité"], ["grant", "Queue grant"]];
|
||||
const TO_ALL = [["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["pan-pan", "Pan-Pan"], ["security", "Sécurité"], ["mayday", "Mayday"]];
|
||||
const TO_ONE = [["post", "Post"], ["call", "Call"]];
|
||||
const STATUS = {
|
||||
running: ["läuft", "ok"], queued: ["wartet", "info"], blocked: ["blockiert", "bad"], stale: ["veraltet", "warn"], pending: ["offen", "warn"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"],
|
||||
approved: ["freigegeben", "ok"], rejected: ["abgelehnt", "bad"], allowed: ["erlaubt", "ok"], denied: ["abgelehnt", "bad"], expired: ["abgelaufen", "neutral"], decided: ["entschieden", "ok"], failed: ["fehlgeschlagen", "bad"], refused: ["verweigert", "bad"],
|
||||
running: ["running", "ok"], queued: ["waiting", "info"], blocked: ["blocked", "bad"], stale: ["stale", "warn"], pending: ["open", "warn"], done: ["done", "neutral"], stopped: ["stopped", "neutral"],
|
||||
approved: ["approved", "ok"], rejected: ["denied", "bad"], allowed: ["allowed", "ok"], denied: ["denied", "bad"], expired: ["expired", "neutral"], decided: ["decided", "ok"], failed: ["failed", "bad"], refused: ["refused", "bad"],
|
||||
};
|
||||
const SECTIONS = {staged: "Gestaged", unstaged: "Nicht gestaged", untracked: "Unversioniert"};
|
||||
const SECTIONS = {staged: "Staged", unstaged: "Not staged", untracked: "Untracked"};
|
||||
const FILE_STATUS = {added: ["A", "ok"], deleted: ["D", "bad"], modified: ["M", "info"], renamed: ["R", "violet"], copied: ["C", "violet"], untracked: ["U", "warn"]};
|
||||
const SIGNS = {add: "+", del: "-", ctx: " ", meta: " "};
|
||||
const NODE_KINDS = {operator: "Operator", chair: "Chair", session: "Session", subagent: "Subagent"};
|
||||
@@ -34,16 +33,16 @@
|
||||
const RELAYOUT = 60000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const STORE = {views: "kvb-orchestrator.views"};
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Post und Calls an dich"], ["radio", "Aller Funk"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"]];
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
|
||||
const SPEECH_QUEUE = 20;
|
||||
const SPEECH_QUIET = 60000;
|
||||
const SPEECH_FRESH = 300;
|
||||
const THEME_CHOICES = [["sky", "Himmel (folgt dem Licht draußen)"], ["light", "Immer hell"], ["dark", "Immer dunkel"], ["custom", "Eigene Farben"]];
|
||||
const COLOR_FIELDS = [["top", "Himmel oben"], ["bottom", "Himmel am Horizont"], ["accent", "Akzent"]];
|
||||
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
|
||||
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
|
||||
const THEME_CHOICES = [["sky", "Sky (follows the light outside)"], ["light", "Always light"], ["dark", "Always dark"], ["custom", "Custom colors"]];
|
||||
const COLOR_FIELDS = [["top", "Sky at the top"], ["bottom", "Sky at the horizon"], ["accent", "Accent"]];
|
||||
const STAGE_LABELS = {night: "Night", astronomical: "Astronomical twilight", nautical: "Nautical twilight", civil: "Civil twilight", golden: "Golden hour", day: "Day"};
|
||||
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
const COMPASS = [["N", "North"], ["NE", "Northeast"], ["E", "East"], ["SE", "Southeast"], ["S", "South"], ["SW", "Southwest"], ["W", "West"], ["NW", "Northwest"]];
|
||||
const SUBS_TTL = 60000;
|
||||
const TURN = 10;
|
||||
const ZOOM = 1.2;
|
||||
@@ -60,8 +59,8 @@
|
||||
direct: [[740, 0, 0.2]],
|
||||
radio: [[520, 0, 0.07]],
|
||||
};
|
||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
|
||||
const KEYS = [["Ctrl+K", "Commands, views, agents and AI command"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: next / previous entry"], ["x", "Inbox: select entry"], ["a", "Inbox: grant entry (3 s to undo)"], ["Enter", "Inbox: open entry"], ["/", "Search the stream"], ["h", "Look at the sky: drag turns the view, wheel or two fingers zoom, Esc ends"], ["Arrows · + / − · 0", "Sky (focused): turn · zoom · reset"], ["Drag · wheel", "3D: turn, right drag pans, wheel zooms"],["🎙 hold", "Voice input into the agent field or the command line, never sent automatically"], ["?", "This help"], ["Esc", "Close"]];
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
||||
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
||||
const clock = new Intl.DateTimeFormat("de-DE", {hour: "2-digit", minute: "2-digit"});
|
||||
const relative = new Intl.RelativeTimeFormat("en", {numeric: "auto"});
|
||||
const absolute = new Intl.DateTimeFormat("en-GB", {dateStyle: "medium", timeStyle: "short"});
|
||||
const clock = new Intl.DateTimeFormat("en-GB", {hour: "2-digit", minute: "2-digit"});
|
||||
@@ -1,40 +1,40 @@
|
||||
const DENY_CHANNELS = [["post", true, "sobald sein Zug endet"], ["call", true, "sofort, weckt ihn"], ["newsletter", false, "sobald ihr Zug endet"], ["broadcast", false, "sofort"], ["pan-pan", false, "sofort, dringend"], ["security", false, "sofort, als Sicherheitsmeldung"]];
|
||||
const DENY_CHANNELS = [["post", true, "when its turn ends"], ["call", true, "at once, wakes it"], ["newsletter", false, "when their turn ends"], ["broadcast", false, "at once"], ["pan-pan", false, "at once, urgent"], ["security", false, "at once, as a safety notice"]];
|
||||
const DENY_REASON = 2000;
|
||||
const requesterOf = (item) => item.to || "niemanden (Empfänger unklar)";
|
||||
const requesterOf = (item) => item.to || "nobody (recipient unclear)";
|
||||
|
||||
function denyNow(item, channel, reason) {
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason, to: item.to}], `Ablehnung (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason, to: item.to}], `Denial (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
}
|
||||
|
||||
function denySheet(item) {
|
||||
const name = `deny-${keyOf(item)}`;
|
||||
const choices = DENY_CHANNELS.map(([id, direct, hint]) => h("label", {class: "deny-channel"},
|
||||
h("input", {type: "radio", name, value: id, checked: id === "post"}),
|
||||
h("span", {}, h("strong", {}, CLASSES[id]), h("small", {}, `an ${direct ? requesterOf(item) : "alle"}, ${hint}`))));
|
||||
const reason = h("textarea", {class: "field", maxlength: DENY_REASON, rows: 3, placeholder: "z. B. erst das Gate grün machen"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger"}, icon("bi-x-lg"), "Ablehnen");
|
||||
h("span", {}, h("strong", {}, CLASSES[id]), h("small", {}, `to ${direct ? requesterOf(item) : "all"}, ${hint}`))));
|
||||
const reason = h("textarea", {class: "field", maxlength: DENY_REASON, rows: 3, placeholder: "e.g. make the gate green first"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger"}, icon("bi-x-lg"), "Deny");
|
||||
confirm.addEventListener("click", () => {
|
||||
const picked = choices.map((label) => label.querySelector("input")).find((input) => input.checked);
|
||||
closeSheet();
|
||||
denyNow(item, picked.value, reason.value);
|
||||
});
|
||||
openSheet("Ablehnen", [
|
||||
openSheet("Deny", [
|
||||
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? person(item.agent) : null),
|
||||
h("p", {class: "text"}, item.title),
|
||||
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Kanal"), choices),
|
||||
h("label", {class: "deny-reason"}, "Grund (optional, der Agent liest ihn mit)", reason),
|
||||
h("p", {class: "muted"}, "Der Passkey signiert die Ablehnung samt Kanal, Empfänger und Grund; danach bleiben 3 s zum Rückgängigmachen.",
|
||||
" Mayday bleibt dem Operator vorbehalten."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
|
||||
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Channel"), choices),
|
||||
h("label", {class: "deny-reason"}, "Reason (optional, the agent reads it too)", reason),
|
||||
h("p", {class: "muted"}, "The passkey signs the denial with channel, recipient and reason; then 3 s remain to undo it.",
|
||||
" Mayday stays reserved for the operator."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm)], false);
|
||||
reason.focus();
|
||||
}
|
||||
|
||||
function denySplit(item) {
|
||||
const main = iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => denyNow(item, "post", ""), `Ablehnen: per Post an ${requesterOf(item)}, 3 s rückgängig`);
|
||||
const more = h("button", {type: "button", class: "btn small danger icon-only", "aria-haspopup": "dialog", ...named("Ablehnen über …", "Ablehnen über einen anderen Kanal oder mit Grund")}, caret());
|
||||
const main = iconBtn("bi-x-lg", "Deny", "btn small danger", () => denyNow(item, "post", ""), `Deny: by post to ${requesterOf(item)}, 3 s to undo`);
|
||||
const more = h("button", {type: "button", class: "btn small danger icon-only", "aria-haspopup": "dialog", ...named("Deny via …", "Deny via another channel or with a reason")}, caret());
|
||||
more.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
denySheet(item);
|
||||
});
|
||||
return h("div", {class: "deny-split", role: "group", "aria-label": "Ablehnen"}, main, more);
|
||||
return h("div", {class: "deny-split", role: "group", "aria-label": "Deny"}, main, more);
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
const named = (label, hint) => ({title: hint || label, "aria-label": label});
|
||||
const iconBtn = (name, label, cls, run, hint) => h("button", {type: "button", class: `${cls} icon-only`, ...named(label, hint), on: {click: run}}, icon(name));
|
||||
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
|
||||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
|
||||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "all");
|
||||
const fixed = (value) => value.toFixed(1);
|
||||
const short = (text) => (text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
||||
const btn = (label, cls, run) => h("button", {type: "button", class: cls, on: {click: run}}, label);
|
||||
@@ -32,7 +32,7 @@
|
||||
const diff = ts - nowSeconds();
|
||||
const size = Math.abs(diff);
|
||||
if (size < 45) {
|
||||
return "gerade eben";
|
||||
return "just now";
|
||||
}
|
||||
if (size < 3600) {
|
||||
return relative.format(Math.round(diff / 60), "minute");
|
||||
@@ -65,18 +65,17 @@
|
||||
}
|
||||
const updated = document.getElementById("updated");
|
||||
updated.classList.toggle("offline", ui.offline);
|
||||
updated.textContent = `${ui.offline ? "Keine Verbindung · " : ""}${live() ? "Live" : "Stand"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||||
updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||||
}
|
||||
|
||||
function avatar(name) {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(name)) {
|
||||
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
|
||||
}
|
||||
return EMOJI[hash % EMOJI.length];
|
||||
}
|
||||
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
|
||||
const person = (name) => (name && name !== "-" && name !== "alle" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `${name} öffnen`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||
const faceParts = (key) => Faces.of(key).parts.map(([tag, attrs]) => s(tag, attrs));
|
||||
const talking = (key) => Boolean(speech.current && speech.current.who === key);
|
||||
const face = (who, big) => {
|
||||
const key = agentKey(who);
|
||||
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
|
||||
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
|
||||
};
|
||||
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||
const people = () => state.people || {};
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||||
@@ -85,7 +84,7 @@
|
||||
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
||||
|
||||
function toast(title, lines, good) {
|
||||
const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Schließen"}, h("strong", {}, title), lines.length ? h("pre", {}, lines.join("\n")) : null);
|
||||
const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Close"}, h("strong", {}, title), lines.length ? h("pre", {}, lines.join("\n")) : null);
|
||||
box.addEventListener("click", () => box.remove());
|
||||
document.getElementById("toasts").append(box);
|
||||
window.setTimeout(() => box.remove(), good ? 9000 : 20000);
|
||||
@@ -103,15 +102,15 @@
|
||||
}
|
||||
|
||||
function copyButton(text) {
|
||||
const button = iconBtn("bi-clipboard", "Kopieren", "btn small ghost", null, "Kopieren: in die Zwischenablage");
|
||||
const button = iconBtn("bi-clipboard", "Copy", "btn small ghost", null, "Copy: to the clipboard");
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
relabel(button, "bi-clipboard-check", "Kopiert");
|
||||
relabel(button, "bi-clipboard-check", "Copied");
|
||||
} catch {
|
||||
relabel(button, "bi-x-circle", "Nicht möglich");
|
||||
relabel(button, "bi-x-circle", "Not possible");
|
||||
}
|
||||
window.setTimeout(() => relabel(button, "bi-clipboard", "Kopieren"), 1600);
|
||||
window.setTimeout(() => relabel(button, "bi-clipboard", "Copy"), 1600);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
@@ -127,7 +126,7 @@
|
||||
function openSheet(title, body, wide) {
|
||||
const sheet = document.getElementById("sheet");
|
||||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeSheet)),
|
||||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Close", "btn small ghost", closeSheet)),
|
||||
h("div", {class: "sheet-body"}, body)));
|
||||
sheet.hidden = false;
|
||||
}
|
||||
|
||||
@@ -77,14 +77,14 @@
|
||||
}
|
||||
|
||||
function saveView() {
|
||||
const name = (window.prompt("Name der Ansicht", filter.saved || "") || "").trim();
|
||||
const name = (window.prompt("View name", filter.saved || "") || "").trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const entry = {name: name.slice(0, 60), kinds: [...activeKinds()], waiting: activeWaiting(), q: filter.q, agent: filter.agent};
|
||||
userViews = [...userViews.filter((view) => view.name !== entry.name), entry];
|
||||
if (!keep(STORE.views, userViews)) {
|
||||
toast("Ansicht nur bis zum Neuladen gespeichert", ["Der Browser erlaubt kein localStorage."], false);
|
||||
toast("View saved only until reload", ["The browser does not allow localStorage."], false);
|
||||
}
|
||||
useSaved(entry);
|
||||
}
|
||||
@@ -113,11 +113,11 @@
|
||||
useSaved(view);
|
||||
}
|
||||
},
|
||||
}}, view.name, h("button", {type: "button", class: "x", ...named(`${view.name} löschen`), on: {click: (event) => {
|
||||
}}, view.name, h("button", {type: "button", class: "x", ...named(`Delete ${view.name}`), on: {click: (event) => {
|
||||
event.stopPropagation();
|
||||
dropView(view.name);
|
||||
}}}, icon("bi-x-lg"))));
|
||||
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Ansicht speichern", "btn small ghost", saveView));
|
||||
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Save view", "btn small ghost", saveView));
|
||||
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
|
||||
customize();
|
||||
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
||||
@@ -131,7 +131,7 @@
|
||||
customize();
|
||||
filter.waiting = !filter.waiting;
|
||||
refilter();
|
||||
}}}, "nur Wartendes");
|
||||
}}}, "only waiting");
|
||||
stream.dom.kinds.replaceChildren(waiting, ...kindChips);
|
||||
const parts = kinds.has("transcript") ? PARTS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(filter.parts.has(id)), on: {click: () => {
|
||||
if (filter.parts.has(id) && filter.parts.size > 1) {
|
||||
@@ -140,10 +140,10 @@
|
||||
filter.parts.add(id);
|
||||
}
|
||||
refilter();
|
||||
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
||||
}}}, label)) : [h("span", {class: "muted"}, "Only with the kind Transcripts.")];
|
||||
stream.dom.parts.replaceChildren(...parts);
|
||||
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
|
||||
h("button", {type: "button", class: "x", ...named("Filter entfernen"), on: {click: () => {
|
||||
h("button", {type: "button", class: "x", ...named("Remove filter"), on: {click: () => {
|
||||
filter.sub = "";
|
||||
filter.session = "";
|
||||
filter.agent = "";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const WORK_CHIPS = {dirty: ["bi-pencil-square", "nicht committet", "warn"], unpushed: ["bi-cloud-upload", "ungepusht", "info"], unmerged: ["bi-signpost-split", "nicht gemergt", "neutral"], task: ["bi-card-checklist", "Aufgabe offen", "violet"]};
|
||||
const FLOW_SIDES = {pull: "zieht vor dem Gate", push: "mergt fertig in"};
|
||||
const WORK_CHIPS = {dirty: ["bi-pencil-square", "not committed", "warn"], unpushed: ["bi-cloud-upload", "unpushed", "info"], unmerged: ["bi-signpost-split", "not merged", "neutral"], task: ["bi-card-checklist", "task open", "violet"]};
|
||||
const FLOW_SIDES = {pull: "pulls before the gate", push: "merges into when done"};
|
||||
|
||||
function workChips(reasons) {
|
||||
if (!reasons || !reasons.length) {
|
||||
return null;
|
||||
}
|
||||
return h("span", {class: "states work", role: "group", "aria-label": "Offene Arbeit"}, reasons.map((reason) => {
|
||||
return h("span", {class: "states work", role: "group", "aria-label": "Open work"}, reasons.map((reason) => {
|
||||
const [name, label, tone] = Object.hasOwn(WORK_CHIPS, reason) ? WORK_CHIPS[reason] : ["bi-question-circle", reason, "neutral"];
|
||||
return h("span", {class: "state", "data-tone": tone, title: `offene Arbeit: ${label}`}, icon(name), label);
|
||||
return h("span", {class: "state", "data-tone": tone, title: `open work: ${label}`}, icon(name), label);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -16,25 +16,25 @@
|
||||
return null;
|
||||
}
|
||||
if (flow.source === "invalid") {
|
||||
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge-Flow ungültig: pkgmgr kann die Flow-Datei nicht lesen");
|
||||
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge flow invalid: pkgmgr cannot read the flow file");
|
||||
}
|
||||
const refs = (side) => (flow[side].length ? flow[side].map((ref) => h("code", {class: "flow-ref", title: `${FLOW_SIDES[side]}: ${ref}`}, ref)) : h("span", {class: "muted"}, "–"));
|
||||
const arrow = () => h("span", {class: "flow-arrow", ...named("fließt nach")}, icon("bi-arrow-right"));
|
||||
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge-Flow: ${flow.pull.join(", ") || "keine Quelle"} nach ${branch || "–"} nach ${flow.push.join(", ") || "kein Ziel"}`},
|
||||
const arrow = () => h("span", {class: "flow-arrow", ...named("flows to")}, icon("bi-arrow-right"));
|
||||
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge flow: ${flow.pull.join(", ") || "no source"} to ${branch || "–"} to ${flow.push.join(", ") || "no target"}`},
|
||||
h("span", {class: "flow-label"}, icon("bi-sign-merge-right"), "Flow"),
|
||||
h("span", {class: "flow-side"}, refs("pull")), arrow(),
|
||||
h("code", {class: "flow-ref here", title: "dieser Branch"}, branch || "–"), arrow(),
|
||||
h("code", {class: "flow-ref here", title: "this branch"}, branch || "–"), arrow(),
|
||||
h("span", {class: "flow-side"}, refs("push")));
|
||||
}
|
||||
|
||||
function flowDetails(flow, branch) {
|
||||
if (!flow) {
|
||||
return h("p", {class: "muted"}, "Kein Merge-Flow gesetzt.");
|
||||
return h("p", {class: "muted"}, "No merge flow set.");
|
||||
}
|
||||
if (flow.source === "invalid") {
|
||||
return flowLine(flow, branch);
|
||||
}
|
||||
return [h("h3", {}, "Merge-Flow"), flowLine(flow, branch), meta([["Zieht vor dem Gate", flow.pull.join(", ")], ["Mergt fertig in", flow.push.join(", ")], ["Quelle", flow.source]])];
|
||||
return [h("h3", {}, "Merge flow"), flowLine(flow, branch), meta([["Pulls before the gate", flow.pull.join(", ")], ["Merges into when done", flow.push.join(", ")], ["Source", flow.source]])];
|
||||
}
|
||||
|
||||
function flowTarget(names, ref) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
function orbit() {
|
||||
const nodes = state.graph.nodes;
|
||||
positions = layout(nodes);
|
||||
const svg = chartFrame(400, "Orbit: Operator in der Mitte; wer zusammen arbeitet oder funkt, rückt zusammen, Stille driften nach außen");
|
||||
const svg = chartFrame(400, "Orbit: operator in the middle; whoever works or radios together moves closer, the silent drift outward");
|
||||
svg.append(...RINGS.map((r) => s("circle", {class: "ring", r})));
|
||||
const edgeLayer = s("g", {});
|
||||
const halos = new Map();
|
||||
@@ -16,7 +16,7 @@
|
||||
const index = halos.get(link.source) || 0;
|
||||
halos.set(link.source, index + 1);
|
||||
edgeLayer.append(s("circle", {class: `halo t-${shade}`, cx: fixed(from.x), cy: fixed(from.y), r: radiusOf(link.source) + 7 + index * 7, "stroke-width": width(link.count)},
|
||||
s("title", {}, `${link.source} an alle: ${words}`)));
|
||||
s("title", {}, `${link.source} to all: ${words}`)));
|
||||
continue;
|
||||
}
|
||||
const to = positions.get(link.target);
|
||||
@@ -45,7 +45,7 @@
|
||||
const angle = spotOf(node.id, fresh).chord;
|
||||
return [node.id, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
|
||||
}));
|
||||
const svg = chartFrame(450, "Chord: Nachrichten zwischen Agenten, Reihenfolge nach Nähe");
|
||||
const svg = chartFrame(450, "Chord: messages between agents, ordered by closeness");
|
||||
const arcs = s("g", {});
|
||||
const point = (angle, r) => `${fixed(r * Math.cos(angle))},${fixed(r * Math.sin(angle))}`;
|
||||
for (const node of nodes) {
|
||||
@@ -91,7 +91,7 @@
|
||||
const angle = at.angle + (index - 1) * 0.05;
|
||||
const length = Math.min(8 + 6 * Math.log2(1 + fan.count), 40);
|
||||
arcs.append(s("line", {class: `tick t-${fan.shade}`, x1: fixed((radius + 22) * Math.cos(angle)), y1: fixed((radius + 22) * Math.sin(angle)), x2: fixed((radius + 22 + length) * Math.cos(angle)), y2: fixed((radius + 22 + length) * Math.sin(angle)), "stroke-width": 4},
|
||||
s("title", {}, `${fan.source} an alle: ${fan.count}×`)));
|
||||
s("title", {}, `${fan.source} to all: ${fan.count}×`)));
|
||||
}
|
||||
svg.append(arcs, s("g", {}, nodes.map((node) => nodeMark(node, spots.get(node.id), 74))));
|
||||
return svg;
|
||||
@@ -110,10 +110,10 @@
|
||||
const height = top + lanes.length * lane + 12;
|
||||
const x = (ts) => left + ((ts - begin) / (end - begin)) * (full - left - right);
|
||||
const y = new Map(lanes.map((node, index) => [node.id, top + index * lane + lane / 2]));
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: Nachrichten über die Zeit"}, defs());
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: messages over time"}, defs());
|
||||
lanes.forEach((node, index) => {
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", on: {click: () => select(node.id)}}, node.label));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
@@ -131,7 +131,7 @@
|
||||
return;
|
||||
}
|
||||
const shade = tone(cls);
|
||||
const hint = s("title", {}, `${clock.format(stampOf(ts))} · ${dash(labelOf(from))} → ${to.length ? to.map(labelOf).join(", ") : "alle"}\n${words}`);
|
||||
const hint = s("title", {}, `${clock.format(stampOf(ts))} · ${dash(labelOf(from))} → ${to.length ? to.map(labelOf).join(", ") : "all"}\n${words}`);
|
||||
if (!to.length) {
|
||||
const rays = [[-6, -13], [0, -15], [6, -13], [-6, 13], [0, 15], [6, 13]].map(([dx, dy]) => `M${x0},${y0} l${dx},${dy}`).join(" ");
|
||||
marks.append(s("g", {class: `mark t-${shade}`}, hint, s("path", {d: rays}), s("circle", {cx: x0, cy: y0, r: 3.5})));
|
||||
@@ -150,7 +150,7 @@
|
||||
arrows(row.class, row.ts, row.source, row.targets, row.text.length > 120 ? `${row.text.slice(0, 120)}…` : row.text);
|
||||
}
|
||||
for (const grant of state.grants) {
|
||||
arrows("grant", grant.ts, chairKey(), [grant.target], `Queue-Freigabe ${grant.kind} (${grant.id})`);
|
||||
arrows("grant", grant.ts, chairKey(), [grant.target], `Queue grant ${grant.kind} (${grant.id})`);
|
||||
}
|
||||
svg.append(marks);
|
||||
return svg;
|
||||
@@ -158,29 +158,23 @@
|
||||
|
||||
function legend() {
|
||||
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
|
||||
h("span", {class: "muted"}, "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit"));
|
||||
h("span", {class: "muted"}, "Line width = count · closeness = recent collaboration"));
|
||||
}
|
||||
|
||||
function side() {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
||||
if (!node) {
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf einen Knoten: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.")];
|
||||
}
|
||||
function nodePopup(node) {
|
||||
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
|
||||
return [
|
||||
h("div", {class: "panel-head"},
|
||||
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => select(node.id))),
|
||||
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
|
||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
|
||||
h("span", {}, row.text)))) : h("p", {class: "muted"}, "Keine Nachrichten im gewählten Zeitraum."),
|
||||
popupHead(null, node.label, h("span", {class: "muted"}, NODE_KINDS[node.kind])),
|
||||
h("div", {class: "popup-body"}, h("h3", {}, "Latest messages", counter(messages.length)),
|
||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
|
||||
h("span", {}, row.text)))) : h("p", {class: "muted"}, "No messages in the chosen time range.")),
|
||||
];
|
||||
}
|
||||
|
||||
function viewSwitch(screen) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "View"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
|
||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
@@ -188,9 +182,8 @@
|
||||
function agentsScreen() {
|
||||
const view = ui.views.agents;
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const toolbar = agentsToolbar();
|
||||
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
||||
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||
graphDom = {stage: stageBox, pulses: null, toolbar};
|
||||
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend());
|
||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
||||
}
|
||||
@@ -107,22 +107,24 @@
|
||||
return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label);
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
ui.node = ui.node === id ? null : id;
|
||||
stage();
|
||||
drawSide();
|
||||
function select(id, anchor, closed) {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === id);
|
||||
if (ui.node !== null && node && node.kind !== "operator") {
|
||||
openAgent(node.kind === "subagent" ? node.parent : id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node.kind === "operator") {
|
||||
openPopup(node.label, nodePopup(node), anchor || pickOf(id), closed);
|
||||
} else {
|
||||
openAgent(node.kind === "subagent" ? node.parent : id, anchor || pickOf(id), closed);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeMark(node, at, gap) {
|
||||
const r = RADIUS[node.kind];
|
||||
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} Aufgaben` : "";
|
||||
const classes = ["node", `k-${node.kind}`, node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} tasks` : "";
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`,
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`, "data-pick": node.id,
|
||||
on: {
|
||||
click: () => select(node.id),
|
||||
keydown: (event) => {
|
||||
@@ -133,9 +135,9 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("circle", {r}),
|
||||
node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()),
|
||||
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
|
||||
caption(node, at, r, gap));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
const LAYOUTS = [
|
||||
["none", "Kraft", "bi-magnet", "Kräfte ordnen frei: Verbundenes zieht sich an, alles stößt sich ab"],
|
||||
["time", "Zeit", "bi-clock", "Tiefe = Zeit: Früheres liegt hinten, Jüngeres vorn"],
|
||||
["spawn", "Spawn-Tiefe", "bi-layers", "Tiefe = Spawn-Tiefe: jede Ebene eine Generation"],
|
||||
["none", "Force", "bi-magnet", "Forces arrange freely: linked nodes attract, everything repels"],
|
||||
["time", "Time", "bi-clock", "Depth = time: earlier lies behind, later in front"],
|
||||
["spawn", "Spawn depth", "bi-layers", "Depth = spawn depth: each level one generation"],
|
||||
];
|
||||
const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"};
|
||||
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"};
|
||||
const SPAWNED = new Set(["twig", "subagent"]);
|
||||
const DEPTH_GAP = 90;
|
||||
const TIME_DEPTH = 640;
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""};
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null};
|
||||
|
||||
const layoutOf = () => space.views.graph.axis;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
tag.nonce = NONCE;
|
||||
tag.async = true;
|
||||
tag.addEventListener("load", resolve);
|
||||
tag.addEventListener("error", () => reject(new Error(`${src} nicht geladen`)));
|
||||
tag.addEventListener("error", () => reject(new Error(`${src} not loaded`)));
|
||||
document.head.append(tag);
|
||||
});
|
||||
}
|
||||
@@ -39,12 +39,13 @@
|
||||
}
|
||||
|
||||
function layoutTools(tab) {
|
||||
const group = h("div", {class: "segmented", role: "group", "aria-label": "Anordnung"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
|
||||
const group = h("div", {class: "segmented", role: "group", "aria-label": "Layout"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
|
||||
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${hint}`), "aria-pressed": String(layoutOf() === id), on: {click: () => chooseLayout(tab, id, group)},
|
||||
}, icon(name))));
|
||||
return h("div", {class: "space-tools"}, group,
|
||||
iconBtn("bi-arrow-counterclockwise", "Ansicht zurücksetzen", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
|
||||
h("span", {class: "muted"}, "Ziehen dreht · rechts ziehen verschiebt · Rad zoomt · Klick öffnet das Panel"));
|
||||
iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
|
||||
h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the popup"),
|
||||
nodeSearch(() => model3d(tab).nodes, (node) => pick3d(tab, node)));
|
||||
}
|
||||
|
||||
function chooseLayout(tab, id, group) {
|
||||
@@ -108,7 +109,7 @@
|
||||
function colors3d() {
|
||||
const css = window.getComputedStyle(document.documentElement);
|
||||
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
|
||||
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text"]);
|
||||
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text", "alarm"]);
|
||||
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
|
||||
return g3.colors;
|
||||
}
|
||||
@@ -141,19 +142,37 @@
|
||||
}
|
||||
|
||||
function pick3d(tab, node) {
|
||||
if (tab === "artifacts") {
|
||||
artSelect(node.id);
|
||||
} else if (node.kind === "twig") {
|
||||
if (node.kind === "twig") {
|
||||
focusSub(node.session, node.sub);
|
||||
} else {
|
||||
select(node.id);
|
||||
return;
|
||||
}
|
||||
(tab === "artifacts" ? artSelect : select)(node.id, anchor3d(node.id), () => mark3d(null));
|
||||
mark3d(node.id);
|
||||
}
|
||||
|
||||
function list3d(tab, data) {
|
||||
const pickable = data.nodes.filter((node) => node.kind !== "operator");
|
||||
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} Knoten als Liste`),
|
||||
h("div", {class: "chips", role: "group", "aria-label": "Knoten"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label))));
|
||||
function anchor3d(id) {
|
||||
return () => {
|
||||
const node = g3.graph && g3.host && g3.host.isConnected ? g3.graph.graphData().nodes.find((entry) => entry.id === id) : null;
|
||||
if (!node || !Number.isFinite(node.x)) {
|
||||
return null;
|
||||
}
|
||||
const at = g3.graph.graph2ScreenCoords(node.x, node.y, node.z);
|
||||
const box = g3.host.querySelector(".graph3d").getBoundingClientRect();
|
||||
return {left: box.left + at.x - 8, top: box.top + at.y - 8, width: 16, height: 16};
|
||||
};
|
||||
}
|
||||
|
||||
function mark3d(id) {
|
||||
g3.picked = id;
|
||||
if (g3.graph) {
|
||||
g3.graph.nodeColor(g3.graph.nodeColor()).nodeVal(g3.graph.nodeVal());
|
||||
}
|
||||
const box = g3.host && g3.host.querySelector(".graph3d");
|
||||
if (box && id) {
|
||||
box.dataset.picked = id;
|
||||
} else if (box) {
|
||||
delete box.dataset.picked;
|
||||
}
|
||||
}
|
||||
|
||||
function signature3d(tab, data) {
|
||||
@@ -176,7 +195,7 @@
|
||||
dispose3d();
|
||||
g3.host = host;
|
||||
g3.tab = tab;
|
||||
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D-Graph ${tab === "agents" ? "der Agenten" : "der Artefakte"}; die Knoten stehen auch darunter als Liste`}), h("div", {class: "space-note"}, "Lade 3D …"));
|
||||
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the search above finds every node`}), h("div", {class: "space-note"}, "Loading 3D …"));
|
||||
}
|
||||
g3.sig = sig;
|
||||
const box = host.querySelector(".graph3d");
|
||||
@@ -190,21 +209,21 @@
|
||||
return;
|
||||
}
|
||||
if (!Graph) {
|
||||
host.replaceChildren(emptyState("3D nicht geladen", g3.failed || "3d-force-graph fehlt."));
|
||||
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
|
||||
return;
|
||||
}
|
||||
if (!g3.graph) {
|
||||
try {
|
||||
g3.graph = Graph({controlType: "orbit"})(box);
|
||||
} catch (error) {
|
||||
host.replaceChildren(emptyState("3D nicht verfügbar", `Dieser Browser gibt kein WebGL: ${error.message || error}`));
|
||||
host.replaceChildren(emptyState("3D unavailable", `This browser offers no WebGL: ${error.message || error}`));
|
||||
g3.host = null;
|
||||
return;
|
||||
}
|
||||
const colors = colors3d();
|
||||
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
|
||||
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1))
|
||||
.nodeColor((node) => g3.colors[KIND_COLORS[node.kind]] || colors.text).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
|
||||
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1) * (node.id === g3.picked ? 4 : 1))
|
||||
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
|
||||
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
|
||||
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
|
||||
g3.graph.warmupTicks(calm.matches ? 160 : 80);
|
||||
@@ -218,8 +237,8 @@
|
||||
place(data.nodes, layout);
|
||||
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
|
||||
drawPlanes(layout, data.nodes);
|
||||
const words = `${data.nodes.length} Knoten · ${data.links.length} Kanten · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · ohne Bewegung (prefers-reduced-motion)" : ""}`;
|
||||
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data));
|
||||
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
|
||||
host.replaceChildren(box, h("div", {class: "space-note"}, words));
|
||||
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const hier = {chart: null, sig: "", closed: new Set(), ids: 0};
|
||||
const OFFICE_ICONS = {chair: "bi-award", line: "bi-person-badge", cluster: "bi-collection", work: "bi-briefcase", other: "bi-signpost-split"};
|
||||
const OFFICE_GROUPS = [["cluster", "Organisatoren", "Organisator"], ["work", "Präsidenten", "Präsident"], ["other", "Weitere Ämter", "Amt"]];
|
||||
const OFFICE_GROUPS = [["cluster", "Organizers", "Organizer"], ["work", "Presidents", "President"], ["other", "Other offices", "Office"]];
|
||||
|
||||
function hierarchyScreen() {
|
||||
hier.chart = h("section", {class: "card org-chart", "aria-labelledby": "org-title"});
|
||||
@@ -55,19 +55,19 @@
|
||||
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
|
||||
return h("div", {class: "org-row", "data-office": claim.office},
|
||||
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `${who} öffnen`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
active ? chip("bi-play-circle", "aktiv", "ok") : chip("bi-pause-circle", "ohne Herzschlag", "neutral"),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
|
||||
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||
h("span", {class: "org-actions"},
|
||||
officeBtn("bi-arrow-left-right", "Übergeben", `Übergeben: ${claim.office} an eine andere Session, mit Passkey-Signatur`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
|
||||
officeBtn("bi-person-x", "Abberufen", `Abberufen: ${claim.office} frei machen, mit Passkey-Signatur`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
|
||||
officeBtn("bi-arrow-left-right", "Hand over", `Hand over: ${claim.office} to another session, with a passkey signature`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
|
||||
officeBtn("bi-person-x", "Recall", `Recall: free ${claim.office}, with a passkey signature`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
|
||||
}
|
||||
|
||||
function vacantRow(office, label) {
|
||||
return h("div", {class: "org-row vacant", "data-office": office},
|
||||
h("span", {class: "org-office"}, icon(OFFICE_ICONS.chair), h("strong", {}, label)),
|
||||
pill("unbesetzt", "warn"),
|
||||
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Besetzen", `Besetzen: ${office} einer Session geben, mit Passkey-Signatur`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
|
||||
pill("vacant", "warn"),
|
||||
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Appoint", `Appoint: give ${office} to a session, with a passkey signature`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
|
||||
}
|
||||
|
||||
function groupRow(glyph, label, count, note) {
|
||||
@@ -81,12 +81,12 @@
|
||||
const open = !hier.closed.has(id);
|
||||
hier.ids += 1;
|
||||
const list = h("ul", {class: "org-tree", id: `org-${hier.ids}`, hidden: !open}, children);
|
||||
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Zuklappen" : "Aufklappen"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
|
||||
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Collapse" : "Expand"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
|
||||
toggle.addEventListener("click", () => {
|
||||
const now = list.hidden;
|
||||
list.hidden = !now;
|
||||
toggle.setAttribute("aria-expanded", String(now));
|
||||
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Zuklappen" : "Aufklappen"}: ${label}`);
|
||||
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Collapse" : "Expand"}: ${label}`);
|
||||
if (now) {
|
||||
hier.closed.delete(id);
|
||||
} else {
|
||||
@@ -103,14 +103,14 @@
|
||||
dimensions.set(claim.dimension, [...(dimensions.get(claim.dimension) || []), claim]);
|
||||
}
|
||||
const lines = [...dimensions].sort(([a], [b]) => a.localeCompare(b)).map(([dimension, held]) => orgNode(`dimension:${dimension}`, dimension,
|
||||
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "Linie" : "Linien"),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Repräsentant"), []))));
|
||||
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "line" : "lines"),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Representative"), []))));
|
||||
const groups = OFFICE_GROUPS.map(([kind, label, title]) => [kind, label, title, claims.filter((entry) => entry.kind === kind)]).filter(([, , , held]) => held.length)
|
||||
.map(([kind, label, title, held]) => orgNode(`group:${kind}`, label, groupRow(OFFICE_ICONS[kind], label, held.length, kind === "other" ? "" : `${kind}/…`),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, kind === "other" ? claim.office : claim.office.slice(kind.length + 1), title), []))));
|
||||
const chair = claims.find((entry) => entry.kind === "chair");
|
||||
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Amt") : vacantRow("chair", "Chair"), [...lines, ...groups]);
|
||||
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "vergibt jedes Amt mit Passkey-Signatur"));
|
||||
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Office") : vacantRow("chair", "Chair"), [...lines, ...groups]);
|
||||
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "assigns every office with a passkey signature"));
|
||||
return h("ul", {class: "org-tree root"}, orgNode("operator", HUMAN, root, [chairNode]));
|
||||
}
|
||||
|
||||
@@ -119,10 +119,10 @@
|
||||
hier.ids = 0;
|
||||
const count = state.claims.length;
|
||||
hier.chart.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchie", counter(count)),
|
||||
h("span", {class: "muted"}, "Formale Ämter aus dem Council: Operator → Chair → Dimensionen → Linien, dazu Organisatoren und Präsidenten"),
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchy", counter(count)),
|
||||
h("span", {class: "muted"}, "Formal offices from the council: Operator → Chair → dimensions → lines, plus organizers and presidents"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("Neues Amt", "Neues Amt: ein freies Amt einer Session geben, mit Passkey-Signatur"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "Neues Amt")),
|
||||
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("New office", "New office: give a free office to a session, with a passkey signature"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "New office")),
|
||||
chartTree());
|
||||
refocus(hier.chart, key);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
const result = await sendJson("/api/inbox/approve", {items: entries, proof: signed.proof});
|
||||
ui.busy = false;
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
toast(`${label}: not possible`, fail(result), false);
|
||||
await load();
|
||||
return false;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
function undoToast(batch, label) {
|
||||
const left = h("span", {class: "muted"});
|
||||
const button = h("button", {type: "button", class: "btn small", ...named("Rückgängig")}, icon("bi-arrow-counterclockwise"), "Rückgängig");
|
||||
const button = h("button", {type: "button", class: "btn small", ...named("Undo")}, icon("bi-arrow-counterclockwise"), "Undo");
|
||||
const box = h("div", {class: "toast wait", role: "status"}, h("strong", {}, label), h("div", {class: "undo-row"}, left, h("span", {class: "spacer"}), button));
|
||||
let timer = 0;
|
||||
const count = () => {
|
||||
@@ -33,7 +33,7 @@
|
||||
load();
|
||||
return;
|
||||
}
|
||||
left.textContent = `wird in ${rest} s wirksam`;
|
||||
left.textContent = `takes effect in ${rest} s`;
|
||||
};
|
||||
timer = window.setInterval(count, 250);
|
||||
button.addEventListener("click", async () => {
|
||||
@@ -41,7 +41,7 @@
|
||||
button.disabled = true;
|
||||
const result = await send(`/api/inbox/undo/${encodeURIComponent(batch.id)}`, "", "application/json");
|
||||
box.remove();
|
||||
toast(result.ok ? `Rückgängig: ${label}` : "Zu spät: schon wirksam", result.ok ? [] : [`${result.status} ${result.text}`.trim()], result.ok);
|
||||
toast(result.ok ? `Undone: ${label}` : "Too late: already in effect", result.ok ? [] : [`${result.status} ${result.text}`.trim()], result.ok);
|
||||
await load();
|
||||
});
|
||||
document.getElementById("toasts").append(box);
|
||||
@@ -57,18 +57,18 @@
|
||||
openItem(item);
|
||||
return null;
|
||||
}
|
||||
return submit([entryOf(item, "approve", item.recommended)], `Entscheidung: ${item.recommended}`);
|
||||
return submit([entryOf(item, "approve", item.recommended)], `Choice: ${item.recommended}`);
|
||||
}
|
||||
return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Ausführen" : "Freigabe"}: ${short(item.title)}`);
|
||||
return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Run" : "Grant"}: ${short(item.title)}`);
|
||||
}
|
||||
|
||||
const rejectOne = (item) => submit([entryOf(item, "reject", "")], `Ablehnung: ${short(item.title)}`);
|
||||
const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Entscheidung: ${label}`);
|
||||
const rejectOne = (item) => submit([entryOf(item, "reject", "")], `Denial: ${short(item.title)}`);
|
||||
const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Choice: ${label}`);
|
||||
|
||||
function bulk(items, label) {
|
||||
const usable = items.filter(selectable);
|
||||
if (!usable.length) {
|
||||
toast(`${label}: nichts freizugeben`, [], false);
|
||||
toast(`${label}: nothing to grant`, [], false);
|
||||
return;
|
||||
}
|
||||
const rows = usable.map((item) => h("li", {},
|
||||
@@ -77,19 +77,19 @@
|
||||
item.kind === "operator" ? h("div", {class: "command"}, h("code", {}, item.command)) : null,
|
||||
item.kind === "operator" ? h("span", {class: "muted"}, `in ${item.directory}`) : null,
|
||||
item.kind === "ask" ? h("div", {class: "command"}, h("code", {}, item.command)) : null));
|
||||
const confirm = h("button", {type: "button", class: "btn primary"}, `${usable.length} freigeben`);
|
||||
const confirm = h("button", {type: "button", class: "btn primary"}, `Grant ${usable.length}`);
|
||||
confirm.addEventListener("click", () => {
|
||||
closeSheet();
|
||||
submit(usable.map((item) => entryOf(item, "approve", item.kind === "decision" ? item.recommended : "")), `${label}: ${usable.length} Einträge`);
|
||||
submit(usable.map((item) => entryOf(item, "approve", item.kind === "decision" ? item.recommended : "")), `${label}: ${usable.length} entries`);
|
||||
});
|
||||
openSheet(label, [h("p", {class: "muted"}, "Jeder Eintrag und jeder volle Befehl, der freigegeben wird. Nach dem Klick signiert der Passkey die ganze Auswahl einmal; danach bleiben 3 s zum Rückgängigmachen."), h("ul", {class: "mini"}, rows), h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
|
||||
openSheet(label, [h("p", {class: "muted"}, "Every entry and every full command that gets granted. After the click the passkey signs the whole selection once; then 3 s remain to undo."), h("ul", {class: "mini"}, rows), h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm)], false);
|
||||
confirm.focus();
|
||||
}
|
||||
|
||||
async function mark(item, until, pinned) {
|
||||
const result = await sendJson("/api/inbox/mark", {kind: item.kind, id: item.id, until, pinned});
|
||||
if (!result.ok) {
|
||||
toast("Merken fehlgeschlagen", [`${result.status} ${result.text}`.trim()], false);
|
||||
toast("Marking failed", [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -116,25 +116,25 @@
|
||||
|
||||
function actionsFor(item) {
|
||||
if (item.held) {
|
||||
return [h("span", {class: "muted"}, "wird wirksam …")];
|
||||
return [h("span", {class: "muted"}, "takes effect …")];
|
||||
}
|
||||
const buttons = [];
|
||||
if (item.kind === "review") {
|
||||
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
|
||||
buttons.push(iconBtn("bi-check-lg", "Grant", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Deny", "btn small danger", () => rejectOne(item)));
|
||||
} else if (item.kind === "ask") {
|
||||
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)));
|
||||
buttons.push(iconBtn("bi-check-lg", "Grant", "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "decision" && item.recommended) {
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Recommendation: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
|
||||
} else if (item.kind === "operator" && item.runnable) {
|
||||
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
|
||||
buttons.push(iconBtn("bi-play-fill", "Run", "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "rule") {
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named("Annehmen", "Annehmen: der Passkey signiert genau diese Regel, dann gilt sie"), on: {click: () => approveOne(item)}}, icon("bi-shield-check"), "Annehmen"));
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named("Accept", "Accept: the passkey signs exactly this rule, then it applies"), on: {click: () => approveOne(item)}}, icon("bi-shield-check"), "Accept"));
|
||||
}
|
||||
if (item.deniable) {
|
||||
buttons.push(denySplit(item));
|
||||
}
|
||||
if (item.job && !jobOpen(item.job)) {
|
||||
buttons.push(iconBtn("bi-x-circle", "Schließen", "btn small ghost", () => dismissJob(item.job), "Schließen: Konsole ausblenden, eine erledigte Zeile verschwindet mit ihr"));
|
||||
buttons.push(iconBtn("bi-x-circle", "Close", "btn small ghost", () => dismissJob(item.job), "Close: hide the console; a done row disappears with it"));
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
@@ -142,19 +142,19 @@
|
||||
function inboxItem(item, index) {
|
||||
const red = item.kind === "review" && item.gate !== "pass";
|
||||
const classes = ["item", isGreen(item) ? "green" : "", red ? "red" : "", item.held ? "held" : "", index === ui.cursor ? "cursor" : ""].filter(Boolean).join(" ");
|
||||
const box = h("input", {type: "checkbox", "aria-label": `${item.title} auswählen`, checked: ui.selected.has(keyOf(item)), disabled: !selectable(item), on: {change: () => toggle(item)}});
|
||||
const box = h("input", {type: "checkbox", "aria-label": `Select ${item.title}`, checked: ui.selected.has(keyOf(item)), disabled: !selectable(item), on: {change: () => toggle(item)}});
|
||||
return h("li", {class: classes, "data-index": index},
|
||||
box,
|
||||
h("div", {},
|
||||
h("div", {class: "head-row"},
|
||||
pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]),
|
||||
item.kind === "review" ? pill(item.gate === "pass" ? "Gate grün" : "Gate rot", item.gate === "pass" ? "ok" : "bad") : null,
|
||||
item.kind === "review" ? pill(item.gate === "pass" ? "Gate green" : "Gate red", item.gate === "pass" ? "ok" : "bad") : null,
|
||||
item.warnings.map((warning) => pill(warning, "bad")),
|
||||
item.pinned ? pill("angeheftet", "violet") : null,
|
||||
item.until ? pill(`bis ${absolute.format(stampOf(item.until))}`, "neutral") : null,
|
||||
item.pinned ? pill("pinned", "violet") : null,
|
||||
item.until ? pill(`until ${absolute.format(stampOf(item.until))}`, "neutral") : null,
|
||||
h("span", {class: "spacer"}), when(item.ts)),
|
||||
h("button", {type: "button", class: "linkish", title: "Öffnen", on: {click: () => openItem(item)}}, item.title),
|
||||
item.agent ? h("div", {class: "muted"}, "von ", person(item.agent)) : null,
|
||||
h("button", {type: "button", class: "linkish", title: "Open", on: {click: () => openItem(item)}}, item.title),
|
||||
item.agent ? h("div", {class: "muted"}, "from ", person(item.agent)) : null,
|
||||
item.kind === "review" ? h("div", {class: "muted"}, item.detail, item.score ? ` · ${item.score}` : "") : null,
|
||||
item.kind === "operator" && item.title !== item.command ? h("div", {class: "command"}, h("code", {}, item.command)) : null,
|
||||
item.kind === "operator" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`, item.detail && item.detail !== "-" ? ` · ${item.detail}` : "") : null,
|
||||
@@ -162,16 +162,16 @@
|
||||
item.kind === "rule" ? ruleRequestBody(item) : null,
|
||||
item.kind === "ask" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`) : null,
|
||||
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, title: `${option.label}: your passkey signs this answer, a post to ${item.agent}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
|
||||
}, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null),
|
||||
h("div", {class: "item-actions"},
|
||||
actionsFor(item),
|
||||
item.kind === "operator" ? copyButton(item.command) : null,
|
||||
h("span", {class: "spacer"}),
|
||||
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: eine Stunde zurückstellen"),
|
||||
iconBtn("bi-sunrise", "morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "morgen: bis morgen 8 Uhr zurückstellen"),
|
||||
item.until ? iconBtn("bi-alarm", "wecken", "btn small ghost", () => mark(item, 0, item.pinned), "wecken: wieder zeigen") : null,
|
||||
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: snooze for one hour"),
|
||||
iconBtn("bi-sunrise", "tomorrow", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "tomorrow: snooze until 8 am tomorrow"),
|
||||
item.until ? iconBtn("bi-alarm", "wake", "btn small ghost", () => mark(item, 0, item.pinned), "wake: show again") : null,
|
||||
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "unpin" : "pin", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
}
|
||||
|
||||
function inboxScreen() {
|
||||
@@ -204,14 +204,14 @@
|
||||
ui.snoozed = event.target.checked;
|
||||
drawInbox();
|
||||
}}});
|
||||
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Auswahl freigeben (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, icon("bi-check2-square"), String(chosen.length));
|
||||
const all = h("button", {type: "button", class: "btn small primary", ...named(`Alle grünen freigeben (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, icon("bi-check2-all"), String(green.length));
|
||||
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Grant selection (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Grant selection")}}, icon("bi-check2-square"), String(chosen.length));
|
||||
const all = h("button", {type: "button", class: "btn small primary", ...named(`Grant all green (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Grant all green")}}, icon("bi-check2-all"), String(green.length));
|
||||
inboxDom.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
|
||||
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
|
||||
hidden ? h("label", {class: "check"}, snoozed, `${hidden} snoozed`) : null),
|
||||
h("div", {class: "inbox-actions"}, choose, all),
|
||||
onLocalhost() ? "" : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
|
||||
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
|
||||
onLocalhost() ? "" : h("p", {class: "muted"}, "The passkey signs grants only under ", localLink()),
|
||||
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("All done", "Nothing is waiting for you right now."));
|
||||
settleConsoles(items);
|
||||
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||
if (current && document.activeElement === document.body) {
|
||||
@@ -232,7 +232,7 @@
|
||||
h("p", {class: "text"}, item.title),
|
||||
item.command ? h("div", {class: "command"}, h("code", {}, item.command), copyButton(item.command)) : null,
|
||||
item.kind === "rule" ? ruleRequestBody(item) : null,
|
||||
meta([["Verzeichnis", item.directory], ["Warnungen", item.warnings.join(", ")], ["Id", item.id]]),
|
||||
meta([["Directory", item.directory], ["Warnings", item.warnings.join(", ")], ["Id", item.id]]),
|
||||
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => {
|
||||
closeSheet();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
function helpSheet() {
|
||||
openSheet("Tastenkürzel", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
|
||||
h("p", {class: "muted"}, "Leitstand: Vollbild mit großer Schrift für den zweiten Bildschirm, unter ⚙ Ambiente. j, k, x, a und Enter wirken in der Inbox.")], false);
|
||||
openSheet("Keyboard shortcuts", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
|
||||
h("p", {class: "muted"}, "Control room: full screen with large type for a second screen, under ⚙ Ambience. j, k, x, a and Enter work in the inbox.")], false);
|
||||
}
|
||||
|
||||
function overlayOpen() {
|
||||
@@ -38,8 +38,8 @@
|
||||
closeSheet();
|
||||
} else if (space.look) {
|
||||
skyLook(false);
|
||||
} else if (ui.agent !== null) {
|
||||
closeAgent();
|
||||
} else if (popupDialog().open) {
|
||||
closePopup();
|
||||
} else {
|
||||
closeMenus();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
announce();
|
||||
render(false);
|
||||
drawTime();
|
||||
drawDrawer();
|
||||
drawAgent();
|
||||
drawStream();
|
||||
if (motion.current.size === 0 || ui.asOf !== motion.asOf) {
|
||||
const first = motion.current.size === 0;
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
return;
|
||||
}
|
||||
button.setAttribute("aria-pressed", String(settings.music.on));
|
||||
relabel(button, "bi-music-note-beamed", settings.music.on ? "Musik an" : "Musik", settings.music.on ? "Musik an: generative Musik ausschalten" : "Musik: generative Musik einschalten");
|
||||
relabel(button, "bi-music-note-beamed", settings.music.on ? "Music on" : "Music", settings.music.on ? "Music on: turn generative music off" : "Music: turn generative music on");
|
||||
}
|
||||
|
||||
function toggleMusic() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const OFFICE_RESULTS = {done: ["erledigt", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||
const OFFICE_RESULTS = {done: ["done", "ok"], refused: ["refused", "bad"], failed: ["failed", "bad"]};
|
||||
const OFFICE_VERBS = {
|
||||
appoint: ["Besetzen", "bi-person-plus", "council.py claim --operator auf dem Host: die gewählte Session erhält das freie Amt."],
|
||||
takeover: ["Übergeben", "bi-arrow-left-right", "council.py takeover --operator auf dem Host: das Amt geht an die gewählte Session, auch wenn der bisherige Inhaber noch lebt."],
|
||||
recall: ["Abberufen", "bi-person-x", "council.py release --operator auf dem Host: das Amt wird frei, auch wenn der Inhaber noch lebt."],
|
||||
appoint: ["Appoint", "bi-person-plus", "council.py claim --operator on the host: the chosen session gets the free office."],
|
||||
takeover: ["Hand over", "bi-arrow-left-right", "council.py takeover --operator on the host: the office goes to the chosen session, even if the current holder is still alive."],
|
||||
recall: ["Recall", "bi-person-x", "council.py release --operator on the host: the office becomes free, even if the holder is still alive."],
|
||||
};
|
||||
const OFFICE_HINT = "Der Host prüft deine Passkey-Signatur selbst und verweigert, wenn sich der Inhaber seit der Signatur geändert hat.";
|
||||
const OFFICE_HINT = "The host checks your passkey signature itself and refuses if the holder changed since the signature.";
|
||||
|
||||
function successors(office, holder) {
|
||||
const [dimension, line] = office.split("/");
|
||||
@@ -19,13 +19,13 @@
|
||||
const office = claim ? claim.office : "";
|
||||
const holder = claim && action !== "appoint" ? claim.session : "";
|
||||
const [verb, glyph, text] = OFFICE_VERBS[action];
|
||||
const name = h("input", {class: "field", "aria-label": "Amt", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/linie, cluster/slug oder work/slug"});
|
||||
const name = h("input", {class: "field", "aria-label": "Office", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/line, cluster/slug or work/slug"});
|
||||
const picks = successors(office, holder);
|
||||
const session = h("select", {class: "field", "aria-label": "Session"}, picks.map(([id, label]) => h("option", {value: id}, label)));
|
||||
const work = h("input", {class: "field", "aria-label": "Arbeit", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "woran die Session in diesem Amt arbeitet"});
|
||||
const work = h("input", {class: "field", "aria-label": "Work", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "what the session works on in this office"});
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const recall = action === "recall";
|
||||
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: mit deiner Passkey-Signatur an den Host übergeben`)}, icon(glyph), verb);
|
||||
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: hand over to the host with your passkey signature`)}, icon(glyph), verb);
|
||||
confirm.addEventListener("click", async () => {
|
||||
const item = {kind: "office", action, office: name.value.trim(), holder, session: recall ? "" : session.value, work: recall ? "" : work.value.trim()};
|
||||
confirm.disabled = true;
|
||||
@@ -33,15 +33,15 @@
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
openSheet(office ? `${office}: ${verb}` : "Neues Amt besetzen", [
|
||||
openSheet(office ? `${office}: ${verb}` : "Appoint a new office", [
|
||||
h("p", {class: "muted"}, text, " ", OFFICE_HINT),
|
||||
holder ? meta([["Inhaber", holderOf(claim) || holder, holder]]) : null,
|
||||
holder ? meta([["Holder", holderOf(claim) || holder, holder]]) : null,
|
||||
h("div", {class: "office-form"},
|
||||
h("label", {}, "Amt", name),
|
||||
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "Keine passende Session bekannt.")),
|
||||
recall ? null : h("label", {}, "Arbeit", work)),
|
||||
h("label", {}, "Office", name),
|
||||
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "No matching session known.")),
|
||||
recall ? null : h("label", {}, "Work", work)),
|
||||
outcome,
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
|
||||
], false);
|
||||
(office ? (recall ? confirm : session) : name).focus();
|
||||
}
|
||||
@@ -49,19 +49,19 @@
|
||||
async function signOffice(items, outcome, verb) {
|
||||
const signed = await proofFor(items, verb);
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Not signed: nothing was handed over."));
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/offices", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${verb}: nicht möglich`, fail(result), false);
|
||||
toast(`${verb}: not possible`, fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted"},`Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit ruft jetzt council.py auf.`));
|
||||
outcome.replaceChildren(h("p", {class: "muted"},`Signed and handed to the host (job ${result.data.id}); the path unit now calls council.py.`));
|
||||
followJob(`/api/offices/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||
const {good, lines} = showJob(data, outcome, "office", OFFICE_RESULTS);
|
||||
toast(`${verb}: ${good ? "erledigt" : "nicht ausgeführt"}`, lines, good);
|
||||
toast(`${verb}: ${good ? "done" : "not carried out"}`, lines, good);
|
||||
load();
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
function paletteItems() {
|
||||
return [
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `View: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `View: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
||||
{label: "Priorität …", hint: "Reihenfolge der Agenten in der Hierarchie", run: openAgentOrder},
|
||||
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
|
||||
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
||||
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
||||
{label: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)},
|
||||
{label: "Leitstand umschalten", hint: "Ambiente", run: toggleLeitstand},
|
||||
{label: "Benachrichtigungen aktivieren", hint: "Ambiente", run: askNotify},
|
||||
{label: "Tastenkürzel", hint: "?", run: helpSheet},
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
|
||||
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
|
||||
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
||||
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
|
||||
{label: "Settings …", hint: "Ambience, location, colors, music, tones, read radio aloud, internet radio", run: settingsSheet},
|
||||
{label: settings.music.on ? "Music off" : "Music on", hint: "Ambience", run: toggleMusic},
|
||||
{label: space.look ? "Leave the sky" : "Look at the sky", hint: "h", run: () => skyLook(!space.look)},
|
||||
{label: "Toggle control room", hint: "Ambience", run: toggleLeitstand},
|
||||
{label: "Enable notifications", hint: "Ambience", run: askNotify},
|
||||
{label: "Keyboard shortcuts", hint: "?", run: helpSheet},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
const items = paletteItems().filter((item) => !q || item.label.toLowerCase().includes(q) || item.hint.toLowerCase().includes(q));
|
||||
if (raw) {
|
||||
items.push(state.llm
|
||||
? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
||||
: {label: "KI-Befehl ist aus", hint: "KVB_ORCHESTRATOR_LLM_URL und KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_ORCHESTRATOR_LLM_URL, KVB_ORCHESTRATOR_LLM_MODEL und KVB_ORCHESTRATOR_LLM_KEY_FILE in orchestrator/.env und installiere neu."], false)});
|
||||
? {label: `AI command: “${raw}”`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
||||
: {label: "AI command is off", hint: "set KVB_ORCHESTRATOR_LLM_URL and KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env", keep: true, run: () => toast("AI command is off", ["Set KVB_ORCHESTRATOR_LLM_URL, KVB_ORCHESTRATOR_LLM_MODEL and KVB_ORCHESTRATOR_LLM_KEY_FILE in orchestrator/.env and reinstall."], false)});
|
||||
}
|
||||
palette.items = items;
|
||||
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
|
||||
@@ -40,7 +40,7 @@
|
||||
drawPalette();
|
||||
}
|
||||
},
|
||||
}}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint))));
|
||||
}}, h("span", {class: item.face ? "who" : null}, item.face ? face(item.face, false) : null, item.label), h("span", {class: "muted"}, item.hint))));
|
||||
const current = palette.list.children[palette.index];
|
||||
if (current) {
|
||||
current.scrollIntoView({block: "nearest"});
|
||||
@@ -60,23 +60,23 @@
|
||||
|
||||
async function askModel(text) {
|
||||
palette.plan = {actions: [], rejected: [], busy: true};
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {class: "muted"}, "Frage das Modell … (nur Befehl, Ids und Titel gehen hinaus)")));
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {class: "muted"}, "Asking the model … (only the command, ids and titles leave the hub)")));
|
||||
const result = await sendJson("/api/nl", {text});
|
||||
if (!palette.plan) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok || !result.data) {
|
||||
palette.plan = null;
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {}, `KI-Befehl fehlgeschlagen: ${result.status} ${result.text}`.trim())));
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {}, `AI command failed: ${result.status} ${result.text}`.trim())));
|
||||
return;
|
||||
}
|
||||
palette.plan = result.data;
|
||||
const actions = result.data.actions.map((action) => h("pre", {}, action.summary));
|
||||
const rejected = result.data.rejected.map((entry) => h("p", {class: "muted"}, `verworfen: ${entry.reason} · ${entry.action}`));
|
||||
const rejected = result.data.rejected.map((entry) => h("p", {class: "muted"}, `discarded: ${entry.reason} · ${entry.action}`));
|
||||
palette.list.replaceChildren(h("li", {class: "palette-plan"},
|
||||
actions.length ? actions : h("p", {class: "muted"}, "Das Modell schlug keine gültige Aktion vor."),
|
||||
actions.length ? actions : h("p", {class: "muted"}, "The model proposed no valid action."),
|
||||
rejected,
|
||||
h("p", {class: "muted"}, actions.length ? "Enter führt genau diese Aktionen aus · Esc bricht ab" : "Esc schließt")));
|
||||
h("p", {class: "muted"}, actions.length ? "Enter runs exactly these actions · Esc cancels" : "Esc closes")));
|
||||
}
|
||||
|
||||
async function executePlan(plan) {
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
function openPalette() {
|
||||
const box = document.getElementById("palette");
|
||||
palette.input = h("input", {type: "text", placeholder: "Ansicht, Agent, Aktion – oder ein Befehl in Worten …", "aria-label": "Befehl", role: "combobox", "aria-expanded": "true"});
|
||||
palette.input = h("input", {type: "text", placeholder: "View, agent, action, or a command in words …", "aria-label": "Command", role: "combobox", "aria-expanded": "true"});
|
||||
palette.list = h("ul", {class: "palette-list", role: "listbox"});
|
||||
palette.index = 0;
|
||||
palette.plan = null;
|
||||
@@ -142,7 +142,7 @@
|
||||
runPalette(palette.index);
|
||||
}
|
||||
});
|
||||
box.replaceChildren(h("div", {class: "palette-box", role: "dialog", "aria-label": "Befehle"}, h("div", {class: "palette-row"}, palette.input, micButton(palette.input)), palette.list));
|
||||
box.replaceChildren(h("div", {class: "palette-box", role: "dialog", "aria-label": "Commands"}, h("div", {class: "palette-row"}, palette.input, micButton(palette.input)), palette.list));
|
||||
box.hidden = false;
|
||||
drawPalette();
|
||||
palette.input.focus();
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
const b64url = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")), (char) => char.charCodeAt(0));
|
||||
const localLink = () => h("a", {href: localUrl()}, localUrl());
|
||||
const KEEPASSXC_LOCALHOST = "KeePassXC lehnt Passkeys auf http://localhost ab, bis in KeePassXC unter Werkzeuge → Einstellungen → Browser-Integration → Fortgeschritten „Nutzung von localhost mit Passkeys zulassen“ (Allow using localhost with passkeys) angehakt ist; die Datenbank muss entsperrt sein. Sonst meldet KeePassXC-Browser „Passkeys request canceled.“ und Chrome öffnet seinen eigenen Passkey-Dialog.";
|
||||
const KEEPASSXC_LOCALHOST = "KeePassXC refuses passkeys on http://localhost until “Allow using localhost with passkeys” is checked in KeePassXC under Tools → Settings → Browser Integration → Advanced; the database must be unlocked. Otherwise KeePassXC-Browser reports “Passkeys request canceled.” and Chrome opens its own passkey dialog.";
|
||||
|
||||
async function assertion(prepared, label) {
|
||||
if (!onLocalhost()) {
|
||||
toast(`${label}: nicht signiert`, [`Der Passkey signiert nur unter ${localUrl()}`], false);
|
||||
toast(`${label}: not signed`, [`The passkey signs only under ${localUrl()}`], false);
|
||||
return null;
|
||||
}
|
||||
if (!prepared.credentials.length) {
|
||||
toast(`${label}: nicht signiert`, ["Kein Passkey gepinnt: Einstellungen → Passkey registrieren"], false);
|
||||
toast(`${label}: not signed`, ["No passkey pinned: Settings → Register passkey"], false);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@@ -27,7 +27,7 @@
|
||||
const answer = credential.response;
|
||||
return {id: b64url(credential.rawId), clientDataJSON: b64url(answer.clientDataJSON), authenticatorData: b64url(answer.authenticatorData), signature: b64url(answer.signature)};
|
||||
} catch (error) {
|
||||
toast(`${label}: nicht signiert`, [`${(error && error.name) || error}: KeePassXC gesperrt oder abgebrochen; nichts wurde gesendet.`], false);
|
||||
toast(`${label}: not signed`, [`${(error && error.name) || error}: KeePassXC locked or canceled; nothing was sent.`], false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
async function signedProof(path, fields, label) {
|
||||
const result = await sendJson(path, fields);
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
toast(`${label}: not possible`, fail(result), false);
|
||||
return null;
|
||||
}
|
||||
if (result.data.payload === null) {
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
async function registerPasskey(output) {
|
||||
if (!onLocalhost()) {
|
||||
output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink()));
|
||||
output.replaceChildren(h("p", {class: "muted"}, "not registered: WebAuthn needs the domain ", localLink()));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -68,11 +68,11 @@
|
||||
const blob = b64url(new TextEncoder().encode(JSON.stringify({id: b64url(credential.rawId), attestationObject: b64url(answer.attestationObject), clientDataJSON: b64url(answer.clientDataJSON)})));
|
||||
const command = `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-orchestrator/approvers.json --credential '${blob}'`;
|
||||
output.replaceChildren(
|
||||
h("p", {class: "muted"}, "Diesen Befehl im Terminal des Hosts ausführen, außerhalb jeder Sandbox; erst dann gilt der Passkey. Der Hub pinnt nie selbst."),
|
||||
h("p", {class: "muted"}, "Run this command in the host's terminal, outside any sandbox; only then does the passkey apply. The hub never pins by itself."),
|
||||
h("div", {class: "command"}, h("code", {}, command), copyButton(command)),
|
||||
h("label", {}, "Credential", h("textarea", {class: "field", readonly: true, rows: 3}, blob)));
|
||||
} catch (error) {
|
||||
output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
|
||||
output.replaceChildren(h("p", {class: "muted"}, `not registered: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
const output = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const keys = state.approvers.length
|
||||
? h("ul", {class: "mini"}, state.approvers.map((key) => h("li", {}, pill(key.alg, "ok"), " ", h("code", {title: key.id}, `${key.id.slice(0, 16)}…`))))
|
||||
: h("p", {class: "muted"}, "Kein Passkey gepinnt: Runner, Hook und Commit-Skills verweigern jede Freigabe.");
|
||||
: h("p", {class: "muted"}, "No passkey pinned: runner, hook and commit skills refuse every grant.");
|
||||
return h("section", {}, h("h3", {}, "Passkey"),
|
||||
h("p", {class: "muted"}, "Freigaben von Asks, Reviews und Operator-Befehlen signiert ein Passkey in KeePassXC; Runner, Hook und Commit-Skills prüfen die Signatur selbst gegen die gepinnten Schlüssel."),
|
||||
h("p", {class: "muted"}, "A passkey in KeePassXC signs grants of asks, reviews and operator commands; runner, hook and commit skills check the signature themselves against the pinned keys."),
|
||||
keys,
|
||||
h("p", {class: "muted"}, KEEPASSXC_LOCALHOST),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()),
|
||||
h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn needs the domain: ", localLink()),
|
||||
h("div", {}, btn("Register passkey", "btn small soft", () => registerPasskey(output))),
|
||||
output);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const pop = {anchor: null, marked: null, closed: null};
|
||||
const HITS = 8;
|
||||
const popupDialog = () => document.getElementById("popup");
|
||||
const popupBox = () => popupDialog().querySelector(".popup-box");
|
||||
const near = (selector) => () => document.querySelector(selector);
|
||||
// Trip-wire: artifact ids hold NUL, which CSS.escape turns into U+FFFD, so an attribute selector never matches them.
|
||||
const pickOf = (id) => () => [...document.querySelectorAll("#main [data-pick]")].find((node) => node.dataset.pick === id) || null;
|
||||
const popupHead = (lead, title, ...sub) => h("div", {class: "popup-head"}, lead, h("div", {}, h("h2", {class: "panel-title"}, title), sub), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Close", "btn small ghost", closePopup));
|
||||
|
||||
function openPopup(label, body, anchor, closed) {
|
||||
closePopup();
|
||||
pop.anchor = anchor || null;
|
||||
pop.closed = closed || null;
|
||||
popupDialog().setAttribute("aria-label", label);
|
||||
popupBox().replaceChildren(...body);
|
||||
popupDialog().show();
|
||||
placePopup();
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
if (!popupDialog().open) {
|
||||
return;
|
||||
}
|
||||
popupDialog().close();
|
||||
markPick(null);
|
||||
const closed = pop.closed;
|
||||
pop.anchor = null;
|
||||
pop.closed = null;
|
||||
if (closed) {
|
||||
closed();
|
||||
}
|
||||
}
|
||||
|
||||
function markPick(target) {
|
||||
if (pop.marked && pop.marked !== target) {
|
||||
pop.marked.classList.remove("picked");
|
||||
}
|
||||
pop.marked = target;
|
||||
if (target) {
|
||||
target.classList.add("picked");
|
||||
}
|
||||
}
|
||||
|
||||
function placePopup() {
|
||||
const dialog = popupDialog();
|
||||
if (!dialog.open) {
|
||||
return;
|
||||
}
|
||||
const found = pop.anchor ? pop.anchor() : null;
|
||||
const target = found instanceof Element && found.isConnected ? found : null;
|
||||
markPick(target);
|
||||
const shape = target ? target.querySelector("circle") || target : null;
|
||||
const box = popupBox();
|
||||
box.style.maxHeight = "";
|
||||
const at = Popup.place(shape ? shape.getBoundingClientRect() : found instanceof Element ? null : found, {width: box.offsetWidth, height: box.offsetHeight}, {width: dialog.clientWidth, height: dialog.clientHeight});
|
||||
box.style.left = `${at.left}px`;
|
||||
box.style.top = `${at.top}px`;
|
||||
box.style.maxHeight = at.height === null ? "" : `${at.height}px`;
|
||||
const line = dialog.querySelector(".leader line");
|
||||
line.setAttribute("display", at.line ? "inline" : "none");
|
||||
for (const [key, value] of Object.entries(at.line || {})) {
|
||||
line.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSearch(nodes, pick) {
|
||||
const list = h("div", {class: "chips hits", role: "group", "aria-label": "Search hits"});
|
||||
const field = h("input", {type: "search", class: "field", placeholder: "Find a node …", "aria-label": "Find a node", on: {
|
||||
input: () => list.replaceChildren(...Popup.hits(nodes(), field.value, HITS).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-hit": node.id, on: {click: () => pick(node)}}, node.label))),
|
||||
keydown: (event) => {
|
||||
const [first] = event.key === "Enter" ? Popup.hits(nodes(), field.value, 1) : [];
|
||||
if (first) {
|
||||
event.preventDefault();
|
||||
pick(first);
|
||||
}
|
||||
},
|
||||
}});
|
||||
return h("div", {class: "node-search"}, field, list);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
const PLAY_REFUSED = {
|
||||
NotAllowedError: ["blocked", "Klick nötig", "Der Browser blockiert automatisches Abspielen: ein Klick auf ▶ oder irgendwo auf die Seite startet den Sender."],
|
||||
NotSupportedError: ["error", "nicht abspielbar", "Nicht abspielbar: der Browser versteht Format oder Adresse nicht, oder der Sender leitet auf eine Adresse um, von der die Seite keinen Ton laden darf."],
|
||||
NotAllowedError: ["blocked", "Click needed", "The browser blocks autoplay: a click on ▶ or anywhere on the page starts the station."],
|
||||
NotSupportedError: ["error", "not playable", "Not playable: the browser does not understand the format or the address, or the station redirects to an address the page may not load sound from."],
|
||||
};
|
||||
const MEDIA_ERRORS = {1: "Laden abgebrochen", 2: "Netzwerkfehler", 3: "Ton nicht dekodierbar", 4: "Format oder Adresse nicht abspielbar"};
|
||||
const RESOLVE_STEP = "Radio → Sender bearbeiten: ändere die Adresse des Senders, oder entferne ihn, speichere, lege ihn neu an und speichere wieder; dann löst der Server die Weiterleitung neu auf und gibt ihr Ziel frei.";
|
||||
const MEDIA_ERRORS = {1: "Loading aborted", 2: "Network error", 3: "Sound not decodable", 4: "Format or address not playable"};
|
||||
const RESOLVE_STEP = "Radio → Edit stations: change the station's address, or remove it, save, add it again and save once more; then the server resolves the redirect again and releases its target.";
|
||||
|
||||
function radioVolume() {
|
||||
if (radio.element) {
|
||||
@@ -47,22 +47,22 @@
|
||||
told.box.remove();
|
||||
}
|
||||
const stream = settings.streams[radio.index];
|
||||
const box = toast(`Radio ${kind === "blocked" ? "blockiert" : "stumm"}: ${stream ? stream.name : radio.url}`, [note], false);
|
||||
const box = toast(`Radio ${kind === "blocked" ? "blocked" : "silent"}: ${stream ? stream.name : radio.url}`, [note], false);
|
||||
radio.told = {...fresh, status: [kind, label, note], box};
|
||||
}
|
||||
|
||||
function redirectNote(stream) {
|
||||
if (stream && stream.via === null) {
|
||||
return "Der Server hat die Weiterleitung dieses Senders noch nicht aufgelöst; ein Speichern nach zehn Minuten versucht es erneut.";
|
||||
return "The server has not resolved this station's redirect yet; saving after ten minutes tries again.";
|
||||
}
|
||||
const released = stream ? stream.via : [];
|
||||
const unloaded = released.filter((origin) => !pageMedia.has(origin));
|
||||
if (unloaded.length) {
|
||||
return `Der Sender leitet auf ${unloaded.join(", ")} um; die geladene Seite darf von dort noch keinen Ton laden: Seite neu laden.`;
|
||||
return `The station redirects to ${unloaded.join(", ")}; the loaded page may not load sound from there yet: reload the page.`;
|
||||
}
|
||||
return released.length
|
||||
? `Der Sender leitet auf eine Adresse außerhalb der freigegebenen ${released.join(", ")} um. ${RESOLVE_STEP}`
|
||||
: `Der Sender leitet auf eine Adresse um, die noch nicht freigegeben ist; der Browser nennt das Ziel einer Weiterleitung nicht. ${RESOLVE_STEP}`;
|
||||
? `The station redirects to an address outside the released ${released.join(", ")}. ${RESOLVE_STEP}`
|
||||
: `The station redirects to an address that is not released yet; the browser does not name the target of a redirect. ${RESOLVE_STEP}`;
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
@@ -100,7 +100,7 @@
|
||||
const missing = stream ? Ambience.stationOrigins(stream).filter((origin) => !pageMedia.has(origin)) : [];
|
||||
radio.index = index;
|
||||
if (missing.length) {
|
||||
radioStatus("reload", "lädt neu", `Neue Adresse ${missing.join(", ")}: die Seite lädt neu, damit der Browser von dort Ton laden darf.`);
|
||||
radioStatus("reload", "reloading", `New address ${missing.join(", ")}: the page reloads so the browser may load sound from there.`);
|
||||
if (missing.every((origin) => state.media.includes(origin))) {
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
radio.url = url;
|
||||
radio.waiting = false;
|
||||
radioStatus(url ? "loading" : "", url ? "verbindet" : "", url ? "Verbindet mit dem Sender …" : "");
|
||||
radioStatus(url ? "loading" : "", url ? "connecting" : "", url ? "Connecting to the station …" : "");
|
||||
fadeRadio(0, () => {
|
||||
if (radio.url !== url) {
|
||||
return;
|
||||
@@ -121,7 +121,7 @@
|
||||
radio.element.play().then(() => {
|
||||
radio.playing = true;
|
||||
radio.told = null;
|
||||
radioStatus("playing", "spielt", `spielt seit ${clock.format(new Date())}`);
|
||||
radioStatus("playing", "playing", `playing since ${clock.format(new Date())}`);
|
||||
tuneMusic(FADE / 1000);
|
||||
fadeRadio(1, () => null);
|
||||
}).catch((error) => {
|
||||
@@ -129,7 +129,7 @@
|
||||
return;
|
||||
}
|
||||
radio.waiting = true;
|
||||
const [kind, label, note] = PLAY_REFUSED[error.name] || ["error", "Fehler", `${error.name}: ${error.message}`];
|
||||
const [kind, label, note] = PLAY_REFUSED[error.name] || ["error", "Error", `${error.name}: ${error.message}`];
|
||||
radioTrouble(kind, label, note, "refused");
|
||||
});
|
||||
});
|
||||
@@ -159,13 +159,13 @@
|
||||
radio.playing = false;
|
||||
radio.waiting = true;
|
||||
tuneMusic(FADE / 1000);
|
||||
const reason = failure ? MEDIA_ERRORS[failure.code] || `Fehler ${failure.code}` : "unbekannter Fehler";
|
||||
radioTrouble("error", "Fehler", `Der Sender spielt nicht: ${reason}${failure && failure.message ? ` (${failure.message})` : ""}.`, "media");
|
||||
const reason = failure ? MEDIA_ERRORS[failure.code] || `Error ${failure.code}` : "unknown error";
|
||||
radioTrouble("error", "Error", `The station does not play: ${reason}${failure && failure.message ? ` (${failure.message})` : ""}.`, "media");
|
||||
});
|
||||
document.addEventListener("securitypolicyviolation", (event) => {
|
||||
// Trip-wire: for a redirected load, blockedURI is the station's own URL, never the redirect target.
|
||||
if (event.effectiveDirective === "media-src" && radio.url && !event.blockedURI.startsWith(window.location.origin)) {
|
||||
radioTrouble("error", "gesperrt", redirectNote(settings.streams[radio.index]), "csp");
|
||||
radioTrouble("error", "blocked", redirectNote(settings.streams[radio.index]), "csp");
|
||||
}
|
||||
});
|
||||
return element;
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
let radioDom = null;
|
||||
|
||||
function voiceReplay(note) {
|
||||
return typeof note === "string" && NOTE_ID.test(note) ? h("audio", {class: "voice-replay", controls: true, preload: "none", src: `/api/voice/${note}.wav`, "aria-label": "Sprachnachricht abspielen"}) : null;
|
||||
return typeof note === "string" && NOTE_ID.test(note) ? h("audio", {class: "voice-replay", controls: true, preload: "none", src: `/api/voice/${note}.wav`, "aria-label": "Play voice message"}) : null;
|
||||
}
|
||||
|
||||
function daysText(days) {
|
||||
return {"0,1,2,3,4,5,6": "täglich", "0,1,2,3,4": "werktags", "5,6": "am Wochenende"}[days.join(",")] || days.map((day) => WEEKDAYS[day]).join(" ");
|
||||
return {"0,1,2,3,4,5,6": "daily", "0,1,2,3,4": "weekdays", "5,6": "on weekends"}[days.join(",")] || days.map((day) => WEEKDAYS[day]).join(" ");
|
||||
}
|
||||
|
||||
function scheduleOf(stream) {
|
||||
return `${daysText(stream.days)}, ${stream.from === "00:00" && stream.to === "24:00" ? "rund um die Uhr" : `${stream.from} bis ${stream.to}`}`;
|
||||
return `${daysText(stream.days)}, ${stream.from === "00:00" && stream.to === "24:00" ? "around the clock" : `${stream.from} to ${stream.to}`}`;
|
||||
}
|
||||
|
||||
function stationState(stream, index) {
|
||||
@@ -19,20 +19,20 @@
|
||||
return [radio.state, radio.short, radio.note];
|
||||
}
|
||||
if (!stream.on) {
|
||||
return ["off", "aus", "In den Einstellungen ausgeschaltet"];
|
||||
return ["off", "off", "Switched off in the settings"];
|
||||
}
|
||||
if (!Ambience.activeStream([stream], Ambience.slotOf(new Date()))) {
|
||||
return ["idle", "nicht dran", `Spielt ${scheduleOf(stream)}; ▶ spielt ihn jetzt`];
|
||||
return ["idle", "not due", `Plays ${scheduleOf(stream)}; ▶ plays it now`];
|
||||
}
|
||||
return radio.pick === -1 ? ["stopped", "gestoppt", "Von Hand gestoppt; ▶ spielt ihn wieder"] : ["due", "dran", "Dran, sobald kein früherer Sender spielt"];
|
||||
return radio.pick === -1 ? ["stopped", "stopped", "Stopped by hand; ▶ plays it again"] : ["due", "due", "Due as soon as no earlier station plays"];
|
||||
}
|
||||
|
||||
function stationRow(stream, index) {
|
||||
const [kind, label, note] = stationState(stream, index);
|
||||
const live = index === radio.index && (kind === "playing" || kind === "loading");
|
||||
const action = live
|
||||
? iconBtn("bi-stop-fill", "Stopp", "btn small ghost", stopStation, `Stopp: ${stream.name} anhalten`)
|
||||
: iconBtn("bi-play-fill", "Abspielen", "btn small ghost", () => playStation(index), `Abspielen: ${stream.name} jetzt hören`);
|
||||
? iconBtn("bi-stop-fill", "Stop", "btn small ghost", stopStation, `Stop: pause ${stream.name}`)
|
||||
: iconBtn("bi-play-fill", "Play", "btn small ghost", () => playStation(index), `Play: listen to ${stream.name} now`);
|
||||
action.dataset.action = live ? "stop" : "play";
|
||||
return h("li", {class: "station", "data-station": index, "data-state": kind},
|
||||
h("span", {class: "station-name", title: stream.name}, stream.name),
|
||||
@@ -52,26 +52,29 @@
|
||||
radioDom.stationsSig = sig;
|
||||
radioDom.stations.replaceChildren(...(settings.streams.length
|
||||
? settings.streams.map(stationRow)
|
||||
: [h("li", {class: "muted"}, "Noch kein Sender: unter ⚙ Einstellungen → Radio fügst du einen hinzu.")]));
|
||||
: [h("li", {class: "muted"}, "No station yet: add one under ⚙ Settings → Internet radio.")]));
|
||||
}
|
||||
|
||||
function speakerChips() {
|
||||
return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now},
|
||||
h("span", {class: "speaker-face"}, face(entry.now, false)), h("span", {class: "speaker-name"}, entry.now)));
|
||||
h("span", {class: "speaker-face"}, face(key, false)), h("span", {class: "speaker-name"}, entry.now)));
|
||||
}
|
||||
|
||||
function drawSpeaker() {
|
||||
const now = speech.current && speech.current.who ? speech.current : null;
|
||||
for (const art of document.querySelectorAll(".avatar[data-face]")) {
|
||||
art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who));
|
||||
}
|
||||
if (radioDom) {
|
||||
for (const chip of radioDom.speakers.querySelectorAll(".speaker")) {
|
||||
chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who));
|
||||
}
|
||||
radioDom.caption.hidden = !now;
|
||||
radioDom.caption.replaceChildren(...(now ? [
|
||||
h("span", {class: "speaker-face"}, face(nameOf(now.who), false)),
|
||||
h("strong", {}, `${nameOf(now.who)} spricht`),
|
||||
h("span", {class: "speaker-face"}, face(now.who, false)),
|
||||
h("strong", {}, `${nameOf(now.who)} speaking`),
|
||||
h("span", {class: "caption-line", title: now.line}, now.line),
|
||||
iconBtn("bi-stop-fill", "Vorlesen abbrechen", "btn small ghost", cancelSpeech),
|
||||
iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech),
|
||||
] : []));
|
||||
}
|
||||
if (ui.screen === "agents" && (ui.views.agents === "orbit" || ui.views.agents === "chord")) {
|
||||
@@ -83,9 +86,9 @@
|
||||
const rows = state.messages.slice(-5).reverse();
|
||||
return rows.length ? h("ul", {class: "mini"}, rows.map((message) => h("li", {},
|
||||
h("div", {class: "row"}, klass(message.class || "post"), when(message.created_ts)),
|
||||
h("span", {class: "muted"}, `an ${message.to === "*" ? "alle" : message.to}`),
|
||||
h("span", {class: "muted"}, `to ${message.to === "*" ? "all" : message.to}`),
|
||||
h("span", {}, short(message.text)),
|
||||
voiceReplay(message.voice)))) : h("p", {class: "muted"}, "Im gewählten Zeitraum noch nichts gesendet.");
|
||||
voiceReplay(message.voice)))) : h("p", {class: "muted"}, "Nothing sent in the chosen time range yet.");
|
||||
}
|
||||
|
||||
function drawRadio() {
|
||||
@@ -113,19 +116,19 @@
|
||||
}
|
||||
|
||||
function radioScreen() {
|
||||
const speakers = h("ul", {class: "speakers", "aria-label": "Stationen"});
|
||||
const speakers = h("ul", {class: "speakers", "aria-label": "Speakers"});
|
||||
const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true});
|
||||
const stations = h("ul", {class: "stations", "aria-label": "Sender"});
|
||||
const stations = h("ul", {class: "stations", "aria-label": "Stations"});
|
||||
const outbox = h("div", {});
|
||||
const {form, recipient} = composerForm();
|
||||
radioDom = {speakers, caption, stations, outbox, recipient, crew: "", names: "", sent: "", stationsSig: ""};
|
||||
return h("div", {class: "stack radio-view"},
|
||||
h("h2", {class: "title"}, "Radio"),
|
||||
h("div", {class: "radio-grid"},
|
||||
h("section", {class: "card radio-talk", "aria-label": "Funk"}, h("h3", {class: "group-title"}, "Funk"), caption, speakers, form),
|
||||
h("section", {class: "card radio-talk", "aria-label": "Radio"}, h("h3", {class: "group-title"}, "Radio"), caption, speakers, form),
|
||||
h("div", {class: "stack"},
|
||||
h("section", {class: "card", "aria-label": "Internetradio"},
|
||||
h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internetradio"), btn("Sender bearbeiten", "btn small ghost", settingsSheet)),
|
||||
h("section", {class: "card", "aria-label": "Internet radio"},
|
||||
h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internet radio"), btn("Edit stations", "btn small ghost", settingsSheet)),
|
||||
stations),
|
||||
h("section", {class: "card", "aria-label": "Zuletzt gesendet"}, h("h3", {class: "group-title"}, "Zuletzt gesendet"), outbox))));
|
||||
h("section", {class: "card", "aria-label": "Recently sent"}, h("h3", {class: "group-title"}, "Recently sent"), outbox))));
|
||||
}
|
||||
@@ -33,20 +33,20 @@
|
||||
const review = reviewOf(ui.review);
|
||||
if (!review) {
|
||||
closeSheet();
|
||||
toast("Review nicht mehr im Zeitraum", [], false);
|
||||
toast("Review no longer in the time range", [], false);
|
||||
return;
|
||||
}
|
||||
const item = state.inbox.find((entry) => entry.kind === "review" && entry.id === review.id);
|
||||
const list = fileList(review.id);
|
||||
const treeNav = h("nav", {class: "card tree", "aria-label": "Geänderte Dateien"}, treePane(list));
|
||||
const treeNav = h("nav", {class: "card tree", "aria-label": "Changed files"}, treePane(list));
|
||||
const diff = h("section", {class: "card diff-pane", "aria-label": "Diff"}, diffPane(list));
|
||||
const id = ui.review;
|
||||
const head = h("article", {class: "card"},
|
||||
h("div", {class: "card-head"}, h("div", {}, h("p", {class: "muted"}, "eingereicht ", when(review.created_ts))), h("div", {class: "who"}, status(review.status || "pending"), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null)),
|
||||
h("div", {class: "card-head"}, h("div", {}, h("p", {class: "muted"}, "submitted ", when(review.created_ts))), h("div", {class: "who"}, status(review.status || "pending"), review.gate ? pill(review.gate === "pass" ? "Gate green" : "Gate red", review.gate === "pass" ? "ok" : "bad") : null)),
|
||||
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
|
||||
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
|
||||
h("div", {class: "actions"},
|
||||
h("a", {class: "btn ghost icon-only", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer", ...named("Vollständige Seite öffnen", "Vollständige Seite öffnen (neuer Tab)")}, icon("bi-box-arrow-up-right")),
|
||||
h("a", {class: "btn ghost icon-only", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer", ...named("Open full page", "Open full page (new tab)")}, icon("bi-box-arrow-up-right")),
|
||||
h("span", {class: "spacer"}),
|
||||
item ? actionsFor(item).map((node) => {
|
||||
node.addEventListener("click", closeSheet);
|
||||
@@ -113,14 +113,14 @@
|
||||
|
||||
function treePane(list) {
|
||||
if (list === "loading") {
|
||||
return h("p", {class: "muted pad"}, "Lade Dateiliste …");
|
||||
return h("p", {class: "muted pad"}, "Loading file list …");
|
||||
}
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return h("p", {class: "muted pad"}, "Keine Dateiliste. Die vollständige Seite zeigt alle Änderungen.");
|
||||
return h("p", {class: "muted pad"}, "No file list. The full page shows all changes.");
|
||||
}
|
||||
const sections = [...new Set(list.map((file) => file.section))];
|
||||
return h("div", {}, sections.map((section) => h("div", {class: "tree-section"},
|
||||
sections.length > 1 ? h("h3", {class: "tree-title"}, SECTIONS[section] || section || "Dateien") : null,
|
||||
sections.length > 1 ? h("h3", {class: "tree-title"}, SECTIONS[section] || section || "Files") : null,
|
||||
branch(nest(list.map((file, index) => [file, index]).filter(([file]) => file.section === section)), list))));
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
function diffPane(list) {
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return h("p", {class: "muted pad"}, list === "loading" ? "Lade Änderungen …" : "Kein Diff verfügbar.");
|
||||
return h("p", {class: "muted pad"}, list === "loading" ? "Loading changes …" : "No diff available.");
|
||||
}
|
||||
const file = list[Math.min(ui.file, list.length - 1)];
|
||||
const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"];
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
const COUNTED = {ask: "Asks", review: "Reviews", decision: "Entscheidungen"};
|
||||
const COUNTED = {ask: "asks", review: "reviews", decision: "decisions"};
|
||||
|
||||
function ruleRequestBody(item) {
|
||||
const rule = item.rule;
|
||||
const dry = item.dry_run;
|
||||
const pairs = [["Art", RULE_KINDS[rule.kind]], rule.kind === "ask" ? ["Werkzeug (Glob)", rule.tool] : null, ["Muster (Glob)", rule.pattern], rule.kind === "decision" ? null : ["Verzeichnis (Glob)", rule.scope], ["Läuft ab am", absolute.format(stampOf(rule.expires))]];
|
||||
const pairs = [["Kind", RULE_KINDS[rule.kind]], rule.kind === "ask" ? ["Tool (glob)", rule.tool] : null, ["Pattern (glob)", rule.pattern], rule.kind === "decision" ? null : ["Directory (glob)", rule.scope], ["Expires on", absolute.format(stampOf(rule.expires))]];
|
||||
return h("div", {class: "rule-request"},
|
||||
item.detail ? h("p", {class: "text"}, item.detail) : null,
|
||||
meta(pairs.filter(Boolean)),
|
||||
h("p", {class: "muted dry-run-count"}, "Trockenlauf: Die Regel hätte ", h("strong", {}, `${dry.matched} von ${dry.total}`), ` ${COUNTED[rule.kind]} der letzten ${dry.days} Tage freigegeben.`),
|
||||
h("p", {class: "muted dry-run-count"}, "Dry run: the rule would have granted ", h("strong", {}, `${dry.matched} of ${dry.total}`), ` ${COUNTED[rule.kind]} from the last ${dry.days} days.`),
|
||||
h("p", {class: "muted"}, RULE_HINTS[rule.kind]));
|
||||
}
|
||||
|
||||
async function acceptRule(item) {
|
||||
const label = `Regel: ${short(item.title)}`;
|
||||
const label = `Rule: ${short(item.title)}`;
|
||||
ui.busy = true;
|
||||
const prepared = await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/sign`, {});
|
||||
const signed = prepared.ok && prepared.data ? await assertion(prepared.data, label) : null;
|
||||
if (!prepared.ok || !prepared.data) {
|
||||
toast(`${label}: nicht möglich`, fail(prepared), false);
|
||||
toast(`${label}: not possible`, fail(prepared), false);
|
||||
}
|
||||
const result = signed ? await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/accept`, {rule: prepared.data.payload, assertion: signed}) : null;
|
||||
ui.busy = false;
|
||||
if (result) {
|
||||
toast(result.ok ? `Angelegt: ${short(item.title)}` : `${label}: nicht angelegt`, result.ok ? [] : fail(result), result.ok);
|
||||
toast(result.ok ? `Created: ${short(item.title)}` : `${label}: not created`, result.ok ? [] : fail(result), result.ok);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
const RULE_KINDS = {ask: "Ask: Werkzeug + Befehlsmuster", review: "Review: Branch-Muster + Gate grün", decision: "Entscheidung: Empfehlung nehmen"};
|
||||
const RULE_KINDS = {ask: "Ask: tool + command pattern", review: "Review: branch pattern + gate green", decision: "Decision: take the recommendation"};
|
||||
const RULE_HINTS = {
|
||||
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
|
||||
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
|
||||
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option. Der Passkey signiert die Regel einmal; die Entscheidungen selbst bleiben unsigniert.",
|
||||
ask: "Grants matching asks in matching directories, never a command with a warning (rm -r, push, --force, sudo, pipes into a shell …). The passkey signs the rule once; the hook checks signature and pattern itself.",
|
||||
review: "Grants reviews of matching branches and worktrees, only when their gate is green. The passkey signs the rule once; the commit skill checks signature and pattern itself.",
|
||||
decision: "Takes the recommended option in decisions of matching agents. The passkey signs the rule once; the decisions themselves stay unsigned.",
|
||||
};
|
||||
|
||||
function rulesScreen() {
|
||||
const kind = h("select", {class: "field", "aria-label": "Art"}, Object.entries(RULE_KINDS).map(([value, label]) => h("option", {value}, label)));
|
||||
const kind = h("select", {class: "field", "aria-label": "Kind"}, Object.entries(RULE_KINDS).map(([value, label]) => h("option", {value}, label)));
|
||||
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
|
||||
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
||||
const scope = h("input", {class: "field", type: "text", value: "*", placeholder: "/home/…/Repositories/*"});
|
||||
const expiry = new Date(Date.now() + 7 * DAY * 1000);
|
||||
const expires = h("input", {class: "field", type: "date", value: localInput(expiry.getTime() / 1000).slice(0, 10), required: true});
|
||||
const enabled = h("input", {type: "checkbox", checked: true});
|
||||
const toolField = h("label", {}, "Werkzeug (Glob)", tool);
|
||||
const scopeField = h("label", {}, "Verzeichnis (Glob)", scope);
|
||||
const toolField = h("label", {}, "Tool (glob)", tool);
|
||||
const scopeField = h("label", {}, "Directory (glob)", scope);
|
||||
const hint = h("p", {class: "muted"});
|
||||
const sync = () => {
|
||||
toolField.hidden = kind.value !== "ask";
|
||||
scopeField.hidden = kind.value === "decision";
|
||||
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (Agent-Muster)"}[kind.value];
|
||||
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (agent pattern)"}[kind.value];
|
||||
hint.textContent = RULE_HINTS[kind.value];
|
||||
};
|
||||
kind.addEventListener("change", sync);
|
||||
sync();
|
||||
const form = h("form", {class: "rule-form"}, h("label", {}, "Art", kind), toolField, h("label", {}, "Muster (Glob)", pattern), scopeField, h("label", {}, "Läuft ab am", expires), h("label", {class: "check"}, enabled, "aktiv"), h("button", {type: "submit", class: "btn primary"}, "Regel anlegen"));
|
||||
const form = h("form", {class: "rule-form"}, h("label", {}, "Kind", kind), toolField, h("label", {}, "Pattern (glob)", pattern), scopeField, h("label", {}, "Expires on", expires), h("label", {class: "check"}, enabled, "active"), h("button", {type: "submit", class: "btn primary"}, "Create rule"));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const end = Date.parse(`${expires.value}T23:59:59`) / 1000;
|
||||
@@ -37,21 +37,21 @@
|
||||
};
|
||||
const prepared = await sendJson("/api/rules/sign", fields);
|
||||
if (!prepared.ok || !prepared.data) {
|
||||
toast("Regel nicht angelegt", fail(prepared), false);
|
||||
toast("Rule not created", fail(prepared), false);
|
||||
return;
|
||||
}
|
||||
const signed = prepared.data.sign ? await assertion(prepared.data, "Regel") : null;
|
||||
const signed = prepared.data.sign ? await assertion(prepared.data, "Rule") : null;
|
||||
if (prepared.data.sign && !signed) {
|
||||
return;
|
||||
}
|
||||
const result = await sendJson("/api/rules", {rule: prepared.data.payload, assertion: signed, enabled: enabled.checked});
|
||||
toast(result.ok ? "Regel angelegt" : "Regel nicht angelegt", result.ok ? [] : fail(result), result.ok);
|
||||
toast(result.ok ? "Rule created" : "Rule not created", result.ok ? [] : fail(result), result.ok);
|
||||
await load();
|
||||
});
|
||||
const change = async (rule, verb) => {
|
||||
const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json");
|
||||
if (!result.ok) {
|
||||
toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false);
|
||||
toast("Rule not changed", [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
@@ -60,16 +60,16 @@
|
||||
pill(rule.kind, KIND_TONES[rule.kind]),
|
||||
rule.tool ? h("code", {}, rule.tool) : null,
|
||||
h("code", {}, rule.pattern),
|
||||
rule.kind === "decision" ? null : h("code", {title: "Verzeichnis"}, rule.scope || "–"),
|
||||
pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
|
||||
rule.expires <= now ? pill("abgelaufen", "bad") : h("span", {class: "muted"}, `bis ${absolute.format(stampOf(rule.expires))}`),
|
||||
rule.enabled ? pill("aktiv", "ok") : pill("aus", "neutral"),
|
||||
rule.kind === "decision" ? null : h("code", {title: "Directory"}, rule.scope || "–"),
|
||||
pill(rule.signed ? "signed" : "unsigned: has no effect", rule.signed ? "ok" : "bad"),
|
||||
rule.expires <= now ? pill("expired", "bad") : h("span", {class: "muted"}, `until ${absolute.format(stampOf(rule.expires))}`),
|
||||
rule.enabled ? pill("active", "ok") : pill("off", "neutral"),
|
||||
h("span", {class: "spacer"}),
|
||||
btn(rule.enabled ? "Ausschalten" : "Einschalten", "btn small", () => change(rule, rule.enabled ? "disable" : "enable")),
|
||||
btn("Löschen", "btn small danger", () => {
|
||||
if (window.confirm("Regel löschen?")) {
|
||||
btn(rule.enabled ? "Turn off" : "Turn on", "btn small", () => change(rule, rule.enabled ? "disable" : "enable")),
|
||||
btn("Delete", "btn small danger", () => {
|
||||
if (window.confirm("Delete rule?")) {
|
||||
change(rule, "delete");
|
||||
}
|
||||
})))) : h("p", {class: "muted"}, "Noch keine Regel. Jede automatische Freigabe erscheint im Stream und lässt sich 3 s lang rückgängig machen.");
|
||||
return h("div", {class: "stack"}, h("h2", {class: "title"}, "Regeln für automatische Freigaben"), list, h("div", {class: "card"}, hint, form));
|
||||
})))) : h("p", {class: "muted"}, "No rule yet. Every automatic grant shows up in the stream and can be undone for 3 s.");
|
||||
return h("div", {class: "stack"}, h("h2", {class: "title"}, "Firewall rules for automatic grants"), list, h("div", {class: "card"}, hint, form));
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
document.getElementById("tabs").append(...Nav.TABS.map(([id, label], index) => h("div", {class: "tab-group"},
|
||||
tabButton(id, label, `Taste ${index + 1}`),
|
||||
tabButton(id, label, `key ${index + 1}`),
|
||||
Nav.SUBS[id] ? h("div", {class: "subtabs"}, Nav.SUBS[id].map(([sub, name]) => tabButton(sub, name, ""))) : null)));
|
||||
|
||||
function measure() {
|
||||
@@ -52,9 +52,6 @@
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
|
||||
ui.node = null;
|
||||
}
|
||||
for (const [id, {button}] of navButtons) {
|
||||
button.setAttribute("aria-current", ui.screen === id ? "page" : "false");
|
||||
}
|
||||
@@ -70,6 +67,7 @@
|
||||
const main = document.getElementById("main");
|
||||
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
|
||||
main.replaceChildren(screenOf());
|
||||
document.body.classList.toggle("fit", ["tasks", "inbox"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
|
||||
drawn.screen = ui.screen;
|
||||
drawn.sig = sig;
|
||||
if (g3.host && !g3.host.isConnected) {
|
||||
@@ -77,15 +75,16 @@
|
||||
}
|
||||
}
|
||||
stage();
|
||||
drawSide();
|
||||
drawSpawn();
|
||||
drawHierarchy(force);
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawRadio();
|
||||
drawTasks();
|
||||
drawChoices();
|
||||
drawStatus();
|
||||
tick();
|
||||
placePopup();
|
||||
}
|
||||
|
||||
function drawStatus() {
|
||||
@@ -98,7 +97,7 @@
|
||||
const banner = document.getElementById("mayday");
|
||||
const silence = state.silence;
|
||||
banner.hidden = !silence;
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "aufheben", "btn small", () => confirmAct("clear-mayday"), "aufheben: Mayday beenden (radio.py clear)")] : []));
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday"), "cancel: end the mayday (radio.py clear)")] : []));
|
||||
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
||||
window.setTimeout(measure, 0);
|
||||
}
|
||||
|
||||
@@ -16,25 +16,25 @@
|
||||
}
|
||||
|
||||
async function saveSettings(next) {
|
||||
const signed = await signedProof("/api/settings/sign", next, "Einstellungen");
|
||||
const signed = await signedProof("/api/settings/sign", next, "Settings");
|
||||
if (!signed) {
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/settings", {...next, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast("Einstellungen nicht gespeichert", [`${result.status} ${result.text}`.trim()], false);
|
||||
toast("Settings not saved", [`${result.status} ${result.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
const {settings: saved, problems} = result.data;
|
||||
const unresolved = problems.join("\n");
|
||||
if (unresolved && unresolved !== redirectsTold) {
|
||||
toast("Weiterleitung nicht aufgelöst", [...problems, "Diese Sender laden vorerst nur von ihrer eigenen Adresse; ein Speichern nach zehn Minuten versucht es erneut."], false);
|
||||
toast("Redirect not resolved", [...problems, "These stations load only from their own address for now; saving after ten minutes tries again."], false);
|
||||
}
|
||||
redirectsTold = unresolved;
|
||||
const fresh = [...new Set(saved.streams.flatMap((stream) => Ambience.stationOrigins(stream)))].filter((origin) => !pageMedia.has(origin));
|
||||
adoptSettings(saved);
|
||||
if (fresh.length) {
|
||||
toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf. Blockiert der Browser danach das Abspielen, startet ein Klick den Sender.", `Neu für diese Seite: ${fresh.join(", ")}`], true);
|
||||
toast("New station", ["The page reloads so the browser may play it. If the browser then blocks playback, a click starts the station.", `New for this page: ${fresh.join(", ")}`], true);
|
||||
window.setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
return true;
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
function streamRow(stream, rows, list) {
|
||||
const field = (attrs) => h("input", {class: "field", required: true, ...attrs});
|
||||
const name = field({type: "text", value: stream.name, placeholder: "Sendername", maxlength: 120});
|
||||
const name = field({type: "text", value: stream.name, placeholder: "Station name", maxlength: 120});
|
||||
const url = field({type: "url", value: stream.url, placeholder: "https://…"});
|
||||
const from = field({type: "time", value: stream.from});
|
||||
const to = field({type: "time", value: stream.to === "24:00" ? "00:00" : stream.to});
|
||||
@@ -58,9 +58,9 @@
|
||||
via: stream.via,
|
||||
})};
|
||||
entry.row = h("div", {class: "stream-row"},
|
||||
h("div", {class: "grid"}, h("label", {}, "Name", name), h("label", {}, "Stream-URL", url), h("label", {}, "von", from), h("label", {}, "bis", to)),
|
||||
h("div", {class: "days"}, days.map((box, index) => h("label", {class: "check"}, box, WEEKDAYS[index])), h("label", {class: "check"}, on, "aktiv"), h("span", {class: "spacer"}),
|
||||
btn("Entfernen", "btn small danger", () => {
|
||||
h("div", {class: "grid"}, h("label", {}, "Name", name), h("label", {}, "Stream URL", url), h("label", {}, "from", from), h("label", {}, "to", to)),
|
||||
h("div", {class: "days"}, days.map((box, index) => h("label", {class: "check"}, box, WEEKDAYS[index])), h("label", {class: "check"}, on, "active"), h("span", {class: "spacer"}),
|
||||
btn("Remove", "btn small danger", () => {
|
||||
rows.splice(rows.indexOf(entry), 1);
|
||||
entry.row.remove();
|
||||
})));
|
||||
@@ -70,14 +70,14 @@
|
||||
|
||||
function ambience() {
|
||||
const tool = (id, name, label, title, run) => h("button", {type: "button", id, class: "btn small icon-only", ...named(label, title), on: {click: run}}, icon(name));
|
||||
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
|
||||
tool("sky-look", "bi-stars", "Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
|
||||
const section = h("section", {}, h("h3", {}, "Ambience"), h("div", {class: "toolbar"},
|
||||
tool("sky-look", "bi-stars", "Sky", "Look at the sky: dragging turns the view (key h)", () => {
|
||||
closeSheet();
|
||||
skyLook(true);
|
||||
}),
|
||||
tool("music-toggle", "bi-music-note-beamed", "Musik", "Musik: generative Musik an oder aus", toggleMusic),
|
||||
tool("leitstand", "bi-display", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
||||
tool("notify", "bi-bell", "Benachrichtigungen", "Benachrichtigungen: Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
||||
tool("music-toggle", "bi-music-note-beamed", "Music", "Music: generative music on or off", toggleMusic),
|
||||
tool("leitstand", "bi-display", "Control room", "Control room: full screen with large type", toggleLeitstand),
|
||||
tool("notify", "bi-bell", "Notifications", "Notifications: allow the browser's desktop notifications", askNotify)));
|
||||
window.setTimeout(() => {
|
||||
musicButton();
|
||||
notifyLabel();
|
||||
@@ -87,8 +87,8 @@
|
||||
|
||||
function settingsSheet() {
|
||||
const number = (value, min, max, label) => h("input", {class: "field", type: "number", min, max, step: "any", value, required: true, "aria-label": label});
|
||||
const latitude = number(settings.latitude, -90, 90, "Breite");
|
||||
const longitude = number(settings.longitude, -180, 180, "Länge");
|
||||
const latitude = number(settings.latitude, -90, 90, "Latitude");
|
||||
const longitude = number(settings.longitude, -180, 180, "Longitude");
|
||||
const theme = h("select", {class: "field"}, THEME_CHOICES.map(([id, label]) => h("option", {value: id, selected: settings.theme === id}, label)));
|
||||
const colors = Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, h("input", {type: "color", value: settings.colors[id]})]));
|
||||
const custom = h("div", {class: "grid"}, COLOR_FIELDS.map(([id, label]) => h("label", {}, label, colors[id])));
|
||||
@@ -98,24 +98,25 @@
|
||||
verdict.hidden = theme.value !== "custom";
|
||||
const panel = Ambience.THEMES[Ambience.modeOf(colors.top.value, colors.bottom.value)].panel;
|
||||
const ratio = Ambience.contrast(Ambience.hex(colors.accent.value), Ambience.hex(panel));
|
||||
verdict.textContent = `Akzent auf Flächen: Kontrast ${ratio.toFixed(1)} : 1${ratio < 3 ? " – zu schwach, wähle einen kräftigeren Akzent" : ""}`;
|
||||
verdict.textContent = `Accent on surfaces: contrast ${ratio.toFixed(1)} : 1${ratio < 3 ? "; too weak, pick a stronger accent" : ""}`;
|
||||
};
|
||||
theme.addEventListener("change", judge);
|
||||
Object.values(colors).forEach((input) => input.addEventListener("input", judge));
|
||||
judge();
|
||||
const musicOn = h("input", {type: "checkbox", checked: settings.music.on});
|
||||
const volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Gesamtlautstärke"});
|
||||
const volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Overall volume"});
|
||||
const tones = SOUND_KINDS.map(([id, label]) => {
|
||||
const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} an`});
|
||||
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} Lautstärke`});
|
||||
const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} on`});
|
||||
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} volume`});
|
||||
const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value));
|
||||
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "an"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
|
||||
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "on"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
|
||||
});
|
||||
const squelch = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.squelch, "aria-label": "Squelch"});
|
||||
const notify = h("input", {type: "checkbox", checked: settings.notify});
|
||||
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
|
||||
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY wird immer vorgelesen" : null})}));
|
||||
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY is always read aloud" : null})}));
|
||||
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
|
||||
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `${entry.now} vorlesen`})}));
|
||||
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `Read ${entry.now} aloud`})}));
|
||||
const absent = settings.speech.muted.filter((key) => !people()[key]);
|
||||
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter", who: HUMAN, line: "radio check"}));
|
||||
sample.disabled = !state.speech.tts;
|
||||
@@ -124,24 +125,24 @@
|
||||
settings.streams.forEach((stream) => streamRow(stream, rows, list));
|
||||
const form = h("form", {class: "settings"},
|
||||
ambience(),
|
||||
h("section", {}, h("h3", {}, "Ort"), h("p", {class: "muted"}, "Sonne, Mond und Sterne stehen dort, wo du sie von hier aus siehst, Blick nach Süden; unter 🔭 Himmel (Taste h) drehst du den Blick. Dezimalgrad, Nord und Ost positiv."),
|
||||
h("div", {class: "grid"}, h("label", {}, "Breite", latitude), h("label", {}, "Länge", longitude))),
|
||||
h("section", {}, h("h3", {}, "Darstellung"), h("label", {}, "Farben", theme), custom, verdict),
|
||||
h("section", {}, h("h3", {}, "Musik"), h("p", {class: "muted"}, "Generative Ambient-Musik, offline erzeugt: ruhig bei leerer Inbox, etwas gespannter, wenn etwas wartet, leiser unter Meldungen."),
|
||||
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Musik an"), h("label", {}, "Gesamtlautstärke (Musik und Radio)", volume))),
|
||||
h("section", {}, h("h3", {}, "Töne und Benachrichtigungen"), h("p", {class: "muted"}, "MAYDAY und PAN-PAN sind immer am lautesten. Bei mehreren neuen Ereignissen spielt nur das dringendste."),
|
||||
tones.map((tone) => tone.row), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")),
|
||||
h("section", {}, h("h3", {}, "Funk vorlesen"),
|
||||
h("section", {}, h("h3", {}, "Location"), h("p", {class: "muted"}, "Sun, moon and stars stand where you see them from here, looking south; under 🔭 Sky (key h) you turn the view. Decimal degrees, north and east positive."),
|
||||
h("div", {class: "grid"}, h("label", {}, "Latitude", latitude), h("label", {}, "Longitude", longitude))),
|
||||
h("section", {}, h("h3", {}, "Appearance"), h("label", {}, "Colors", theme), custom, verdict),
|
||||
h("section", {}, h("h3", {}, "Music"), h("p", {class: "muted"}, "Generative ambient music, made offline: calm with an empty inbox, a little tenser when something waits, quieter under messages."),
|
||||
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Music on"), h("label", {}, "Overall volume (music and radio)", volume))),
|
||||
h("section", {}, h("h3", {}, "Tones and notifications"), h("p", {class: "muted"}, "MAYDAY and PAN-PAN are always loudest. With several new events only the most urgent one plays."),
|
||||
tones.map((tone) => tone.row), h("label", {}, "Squelch: scales every tone, 0 plays none", squelch), h("label", {class: "check"}, notify, "Show desktop notifications when the browser allows them")),
|
||||
h("section", {}, h("h3", {}, "Read radio aloud"),
|
||||
h("p", {class: "muted"}, state.speech.tts
|
||||
? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen."
|
||||
: `Der Funk bleibt stumm: ${state.speech.tts_off}.`),
|
||||
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample),
|
||||
h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
|
||||
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
|
||||
h("section", {}, h("h3", {}, "Radio"), h("p", {class: "muted"}, "Internetradio zu festen Zeiten mit 3 s Ein- und Ausblenden; die Musik pausiert solange. Ein neuer Sender läuft rund um die Uhr, bis du seine Zeiten änderst; unter Stream → Radio startest und stoppst du ihn von Hand. Die Seite darf nur von diesen Adressen Ton laden; eine neue Adresse signiert dein Passkey. Leitet ein signierter Sender um, löst der Server die Weiterleitung beim Speichern eines neuen Senders oder einer geänderten Adresse auf und gibt ihr Ziel frei; der Hinweis nach dem Speichern nennt es."),
|
||||
list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "00:00", to: "24:00", days: [0, 1, 2, 3, 4, 5, 6], on: true, via: []}, rows, list)))),
|
||||
? "New radio is read aloud in maritime radio style (SMCP), one message after another, each speaker with its own voice; music and internet radio go quieter meanwhile. The volume follows the tones above; MAYDAY is always spoken, and loudest."
|
||||
: `The radio stays silent: ${state.speech.tts_off}.`),
|
||||
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Read radio aloud"), sample),
|
||||
h("div", {class: "days", role: "group", "aria-label": "Classes"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
|
||||
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Speakers"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
|
||||
h("section", {}, h("h3", {}, "Internet radio"), h("p", {class: "muted"}, "Internet radio at fixed times with a 3 s fade in and out; the music pauses meanwhile. A new station plays around the clock until you change its times; under Stream → Radio you start and stop it by hand. The page may load sound only from these addresses; your passkey signs a new address. If a signed station redirects, the server resolves the redirect when you save a new station or a changed address and releases its target; the note after saving names it."),
|
||||
list, h("div", {}, btn("+ Station", "btn small soft", () => streamRow({name: "", url: "", from: "00:00", to: "24:00", days: [0, 1, 2, 3, 4, 5, 6], on: true, via: []}, rows, list)))),
|
||||
passkeySection(),
|
||||
h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Speichern")));
|
||||
h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Save")));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
context();
|
||||
@@ -152,6 +153,7 @@
|
||||
colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])),
|
||||
music: {on: musicOn.checked, volume: Number(volume.value)},
|
||||
sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])),
|
||||
squelch: Number(squelch.value),
|
||||
notify: notify.checked,
|
||||
streams: rows.map((entry) => entry.read()),
|
||||
speech: {
|
||||
@@ -160,11 +162,12 @@
|
||||
muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)],
|
||||
},
|
||||
space: savedSpace(),
|
||||
estimation: settings.estimation,
|
||||
};
|
||||
if (await saveSettings(next)) {
|
||||
closeSheet();
|
||||
toast("Einstellungen gespeichert", [], true);
|
||||
toast("Settings saved", [], true);
|
||||
}
|
||||
});
|
||||
openSheet("Einstellungen und Ambiente", [form], false);
|
||||
openSheet("Settings and ambience", [form], false);
|
||||
}
|
||||
@@ -56,12 +56,12 @@
|
||||
const view = seenView("sky");
|
||||
const facing = COMPASS[Space.compass(view.azimuth)];
|
||||
const turned = ["azimuth", "altitude", "fov"].some((key) => Math.abs(view[key] - Space.DEFAULTS.sky[key]) >= 0.5);
|
||||
const moonText = moon.alt > 0 ? ` · Mond ${Math.round(moon.lit * 100)} % ${moon.waxing ? "zunehmend" : "abnehmend"}` : "";
|
||||
document.getElementById("sky-note").textContent = `${sun.alt > 0 ? "☀" : "☾"} ${STAGE_LABELS[Ambience.stage(sun.alt)]}${moonText}${turned ? ` · Blick ${facing[0]}` : ""}`;
|
||||
const moonText = moon.alt > 0 ? ` · Moon ${Math.round(moon.lit * 100)} % ${moon.waxing ? "waxing" : "waning"}` : "";
|
||||
document.getElementById("sky-note").textContent = `${sun.alt > 0 ? "☀" : "☾"} ${STAGE_LABELS[Ambience.stage(sun.alt)]}${moonText}${turned ? ` · facing ${facing[0]}` : ""}`;
|
||||
if (space.look) {
|
||||
const words = `Blick nach ${facing[1]} · ${((Math.round(view.azimuth) % 360) + 360) % 360}° · Höhe ${Math.round(view.altitude)}° · Sichtfeld ${Math.round(view.fov)}°`;
|
||||
const words = `Facing ${facing[1]} · ${((Math.round(view.azimuth) % 360) + 360) % 360}° · altitude ${Math.round(view.altitude)}° · field of view ${Math.round(view.fov)}°`;
|
||||
space.compass.textContent = words;
|
||||
sky.canvas.setAttribute("aria-label", `Himmel, ${words}. Pfeiltasten drehen den Blick, Plus und Minus zoomen, 0 setzt zurück, Esc beendet.`);
|
||||
sky.canvas.setAttribute("aria-label", `Sky, ${words}. Arrow keys turn the view, plus and minus zoom, 0 resets, Esc ends.`);
|
||||
}
|
||||
const canvas = sky.canvas;
|
||||
const paint = canvas.getContext("2d");
|
||||
@@ -177,7 +177,7 @@
|
||||
canvas.removeAttribute("aria-hidden");
|
||||
canvas.setAttribute("tabindex", "0");
|
||||
canvas.setAttribute("role", "application");
|
||||
canvas.setAttribute("aria-roledescription", "Himmel");
|
||||
canvas.setAttribute("aria-roledescription", "Sky");
|
||||
drawSky();
|
||||
canvas.focus();
|
||||
return;
|
||||
@@ -194,13 +194,13 @@
|
||||
const step = (name, label, run) => iconBtn(name, label, "btn small", run);
|
||||
space.compass = h("span", {class: "compass", "aria-live": "polite"});
|
||||
panel.append(space.compass,
|
||||
step("bi-caret-left-fill", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("bi-caret-right-fill", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
||||
step("bi-caret-up-fill", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
||||
step("bi-caret-down-fill", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
||||
step("bi-zoom-out", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("bi-zoom-in", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("bi-arrow-counterclockwise", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||
step("bi-check-lg", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||
step("bi-caret-left-fill", "Turn the view left (left arrow)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("bi-caret-right-fill", "Turn the view right (right arrow)", () => skyHooks.turnStep(1, 0)),
|
||||
step("bi-caret-up-fill", "Raise the view (up arrow)", () => skyHooks.turnStep(0, -1)),
|
||||
step("bi-caret-down-fill", "Lower the view (down arrow)", () => skyHooks.turnStep(0, 1)),
|
||||
step("bi-zoom-out", "Widen the field of view (minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("bi-zoom-in", "Zoom in (plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("bi-arrow-counterclockwise", "Reset the view: south, horizon, field of view 180° (key 0)", () => resetView("sky")),
|
||||
step("bi-check-lg", "Leave the sky (Esc)", () => skyLook(false)));
|
||||
steer(sky.canvas, "sky", skyHooks);
|
||||
}
|
||||
@@ -8,12 +8,7 @@
|
||||
return;
|
||||
}
|
||||
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
||||
}
|
||||
|
||||
function drawSide() {
|
||||
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
|
||||
graphDom.aside.replaceChildren(...side());
|
||||
}
|
||||
placePopup();
|
||||
}
|
||||
|
||||
function seenView(kind) {
|
||||
|
||||
@@ -88,10 +88,10 @@
|
||||
}
|
||||
|
||||
function spawnItem(entry) {
|
||||
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "läuft", "ok") : chip("bi-check-circle", "fertig", "neutral");
|
||||
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "running", "ok") : chip("bi-check-circle", "finished", "neutral");
|
||||
const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id);
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Transkript im Stream zeigen" : `${entry.label} öffnen`, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Tiefe ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
|
||||
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
|
||||
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
|
||||
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
|
||||
@@ -116,6 +116,6 @@
|
||||
const all = spawnFlat(root).map(({entry}) => entry).filter((entry) => entry.running !== null);
|
||||
const running = all.filter((entry) => entry.running).length;
|
||||
spawn.dom.list.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn-Baum", counter(all.length)), h("span", {class: "muted"}, `${running} laufen · ${all.length - running} fertig · Operator → Chair → Sessions → Subagenten`)),
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn tree", counter(all.length)), h("span", {class: "muted"}, `${running} running · ${all.length - running} finished · Operator → Chair → Sessions → Subagents`)),
|
||||
h("ul", {class: "spawn-tree root"}, spawnItem(root)));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
function speechFailed(lines) {
|
||||
if (Date.now() - speech.warned > SPEECH_QUIET) {
|
||||
speech.warned = Date.now();
|
||||
toast("Funk vorlesen fehlgeschlagen", lines, false);
|
||||
toast("Reading radio aloud failed", lines, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,16 @@
|
||||
|
||||
function micGap() {
|
||||
return !window.isSecureContext || !navigator.mediaDevices
|
||||
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
|
||||
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
|
||||
? "The microphone needs a secure context: open the hub via 127.0.0.1 or localhost"
|
||||
: !window.MediaRecorder ? "This browser cannot record" : !state.speech.stt ? `Voice input is off: ${state.speech.stt_off}` : "";
|
||||
}
|
||||
|
||||
function micButton(field) {
|
||||
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Spracheingabe", "Spracheingabe: gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet")}, icon("bi-mic"));
|
||||
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Voice input", "Voice input: hold and speak; the text lands in the field and is not sent")}, icon("bi-mic"));
|
||||
const reason = micGap();
|
||||
if (reason) {
|
||||
button.disabled = true;
|
||||
button.title = `Spracheingabe: ${reason}`;
|
||||
button.title = `Voice input: ${reason}`;
|
||||
return button;
|
||||
}
|
||||
const press = (event) => {
|
||||
@@ -119,7 +119,7 @@
|
||||
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
||||
} catch (error) {
|
||||
mic.held = false;
|
||||
toast("Mikrofon nicht verfügbar", [String(error.message || error)], false);
|
||||
toast("Microphone not available", [String(error.message || error)], false);
|
||||
return;
|
||||
}
|
||||
if (!mic.held) {
|
||||
@@ -158,7 +158,7 @@
|
||||
const rate = state.speech.rate;
|
||||
const length = Voice.frames(decoded.duration, rate, state.speech.seconds);
|
||||
if (length < rate / 4) {
|
||||
throw new Error("zu kurz: sprich mindestens eine Viertelsekunde");
|
||||
throw new Error("too short: speak for at least a quarter second");
|
||||
}
|
||||
const offline = new OfflineAudioContext(1, length, rate);
|
||||
const source = offline.createBufferSource();
|
||||
@@ -187,7 +187,7 @@
|
||||
}
|
||||
field.focus();
|
||||
} catch (error) {
|
||||
toast("Spracheingabe fehlgeschlagen", [String(error.message || error)], false);
|
||||
toast("Voice input failed", [String(error.message || error)], false);
|
||||
} finally {
|
||||
mic.busy = false;
|
||||
button.disabled = false;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
||||
@@ -48,7 +48,7 @@
|
||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||
let settings = state.settings;
|
||||
let settingsSig = JSON.stringify(settings);
|
||||
let drawerDom = null;
|
||||
let agentDom = null;
|
||||
let timeDom = null;
|
||||
let graphDom = null;
|
||||
let reviewDom = null;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
holder.textContent = data.text;
|
||||
button.remove();
|
||||
} else {
|
||||
button.textContent = "Nicht mehr verfügbar";
|
||||
button.textContent = "No longer available";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
filter.waiting = false;
|
||||
filter.session = session;
|
||||
filter.sub = sub;
|
||||
closeAgent();
|
||||
closePopup();
|
||||
if (ui.screen === "stream" && stream.dom) {
|
||||
refilter();
|
||||
} else {
|
||||
@@ -84,7 +84,7 @@
|
||||
function extras(entry) {
|
||||
const ref = entry.ref;
|
||||
if (entry.kind === "review") {
|
||||
return [status(ref.status), ref.gate ? pill(ref.gate === "pass" ? "Gate grün" : "Gate rot", ref.gate === "pass" ? "ok" : "bad") : null];
|
||||
return [status(ref.status), ref.gate ? pill(ref.gate === "pass" ? "Gate green" : "Gate red", ref.gate === "pass" ? "ok" : "bad") : null];
|
||||
}
|
||||
if (entry.kind === "ask") {
|
||||
return [status(ref.status), ...ref.warnings.map((warning) => pill(warning, "bad"))];
|
||||
@@ -93,7 +93,7 @@
|
||||
return [status(ref.status), ref.choice ? pill(ref.choice, "ok") : null];
|
||||
}
|
||||
if (entry.kind === "cron") {
|
||||
return [pill(ref.active ? "aktiv" : "inaktiv", ref.active ? "ok" : "neutral")];
|
||||
return [pill(ref.active ? "active" : "inactive", ref.active ? "ok" : "neutral")];
|
||||
}
|
||||
if (entry.kind === "run" || entry.kind === "operator" || entry.kind === "register") {
|
||||
return [ref.status ? status(ref.status) : null];
|
||||
@@ -104,7 +104,7 @@
|
||||
function body(entry) {
|
||||
if (entry.kind === "transcript") {
|
||||
const text = h("pre", {}, entry.text);
|
||||
const more = entry.ref.truncated ? h("button", {type: "button", class: "btn small ghost"}, "Ganzen Text zeigen") : null;
|
||||
const more = entry.ref.truncated ? h("button", {type: "button", class: "btn small ghost"}, "Show full text") : null;
|
||||
if (more) {
|
||||
more.addEventListener("click", () => expand(entry, text, more));
|
||||
}
|
||||
@@ -115,12 +115,12 @@
|
||||
return [h("div", {class: "command"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null];
|
||||
}
|
||||
if (entry.kind === "review") {
|
||||
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, iconBtn("bi-eye", "Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
|
||||
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, iconBtn("bi-eye", "View diff", "btn small soft", () => reviewSheet(entry.ref.id)))];
|
||||
}
|
||||
if (entry.kind === "cron") {
|
||||
const ref = entry.ref;
|
||||
const ended = ref.deleted ? [" · beendet ", when(ref.deleted)] : [];
|
||||
return [entry.text ? h("pre", {}, entry.text) : null, h("span", {class: "muted"}, "angelegt ", when(ref.created), ended)];
|
||||
const ended = ref.deleted ? [" · ended ", when(ref.deleted)] : [];
|
||||
return [entry.text ? h("pre", {}, entry.text) : null, h("span", {class: "muted"}, "created ", when(ref.created), ended)];
|
||||
}
|
||||
if (entry.kind === "run") {
|
||||
return [entry.text ? h("pre", {}, entry.text) : null];
|
||||
@@ -131,7 +131,7 @@
|
||||
function eventRow(entry) {
|
||||
const item = entry.waiting ? inboxOf(entry) : null;
|
||||
const alarm = entry.kind === "radio" && (entry.part === "mayday" || entry.part === "pan-pan");
|
||||
const renamed = entry.now && entry.now !== entry.agent ? h("span", {title: `jetzt: ${entry.now}`}, person(entry.agent)) : person(entry.agent);
|
||||
const renamed = entry.now && entry.now !== entry.agent ? h("span", {title: `now: ${entry.now}`}, person(entry.agent)) : person(entry.agent);
|
||||
const who = entry.agent ? [face(entry.agent, false), renamed] : entry.session ? h("strong", {title: entry.session}, entry.session.slice(0, 8)) : null;
|
||||
const headline = entry.kind === "transcript" || entry.kind === "ask" || entry.kind === "operator" ? null : h("span", {class: "headline"}, entry.title);
|
||||
return h("li", {class: `entry k-${entry.kind}${entry.waiting ? " waiting" : ""}${alarm ? " alarm" : ""}`},
|
||||
@@ -149,33 +149,33 @@
|
||||
const floor = live() && ui.preset > 0 ? nowSeconds() - ui.preset : -Infinity;
|
||||
const rows = [...stream.events, ...stream.entries.filter((entry) => entry.ts >= floor)].sort((a, b) => b.ts - a.ts || (a.id < b.id ? 1 : -1));
|
||||
const shown = rows.slice(0, FEED_LIMIT);
|
||||
stream.dom.count.textContent = `${rows.length} Einträge${live() ? " · folgt live" : ""}`;
|
||||
stream.dom.count.textContent = `${rows.length} entries${live() ? " · following live" : ""}`;
|
||||
if (stream.error) {
|
||||
stream.dom.list.replaceChildren(emptyState("Stream nicht lesbar", stream.error));
|
||||
stream.dom.list.replaceChildren(emptyState("Stream not readable", stream.error));
|
||||
return;
|
||||
}
|
||||
stream.dom.list.replaceChildren(rows.length
|
||||
? h("div", {class: "card flush"}, h("ol", {class: "feed"}, shown.map(eventRow)),
|
||||
rows.length > shown.length ? h("p", {class: "footnote"}, `${rows.length - shown.length} ältere Einträge ausgeblendet; grenze mit Filter oder Zeitraum ein.`) : null)
|
||||
: emptyState(stream.busy ? "Lade …" : "Keine Einträge", activeWaiting() ? "Gerade wartet nichts auf dich." : "Im gewählten Zeitraum und Filter gibt es nichts."));
|
||||
rows.length > shown.length ? h("p", {class: "footnote"}, `${rows.length - shown.length} older entries hidden; narrow down with a filter or the time range.`) : null)
|
||||
: emptyState(stream.busy ? "Loading …" : "No entries", activeWaiting() ? "Nothing is waiting for you right now." : "Nothing in the chosen time range and filter."));
|
||||
}
|
||||
|
||||
function streamScreen() {
|
||||
const search = h("input", {type: "search", placeholder: "Suchen … agent:Name art:funk seit:2h (/)", value: filter.q, "aria-label": "Stream durchsuchen: Wörter, agent:, art: und seit:", on: {input: (event) => {
|
||||
const search = h("input", {type: "search", placeholder: "Search … agent:Name kind:radio since:2h (/)", value: filter.q, "aria-label": "Search the stream: words, agent:, kind: and since:", on: {input: (event) => {
|
||||
filter.q = event.target.value;
|
||||
filter.saved = "";
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(fetchEvents, 300);
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const categories = h("div", {class: "chips", role: "group", "aria-label": "Kategorien"});
|
||||
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"});
|
||||
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transkript-Teile"});
|
||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
||||
const categories = h("div", {class: "chips", role: "group", "aria-label": "Categories"});
|
||||
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Kinds"});
|
||||
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transcript parts"});
|
||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Saved views"});
|
||||
const focus = h("div", {class: "chips"});
|
||||
const list = h("div", {});
|
||||
const more = h("details", {class: "menu"}, h("summary", named("Filter", "Filter: Arten, Transkript-Teile, gespeicherte Ansichten, eigener Zeitraum"), icon("bi-funnel"), caret()), h("div", {class: "menu-box end"},
|
||||
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
|
||||
const more = h("details", {class: "menu"}, h("summary", named("Filter", "Filter: kinds, transcript parts, saved views, custom time range"), icon("bi-funnel"), caret()), h("div", {class: "menu-box end"},
|
||||
h("h3", {}, "Kinds"), kinds, h("h3", {}, "Transcript parts"), parts, h("h3", {}, "Saved views"), saved, h("h3", {}, "Custom time range"), customRange()));
|
||||
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
|
||||
window.setTimeout(() => {
|
||||
drawFilters();
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
const GANTT = {label: 240, right: 28, top: 34, lane: 30, width: 1180, bar: 16, ticks: 4};
|
||||
const BURN = {width: 900, height: 340, left: 52, right: 24, top: 20, bottom: 40};
|
||||
const EDGE_WORDS = {after: "Follows (rasci.py set --after)", base: "Branches from (base branch)", queue: "Waits behind (queue order)"};
|
||||
const SERIES_WORDS = {scope: "Scope (burnup)", done: "Done (burnup)", remaining: "Remaining (burndown)", unestimated: "Unestimated tasks"};
|
||||
const LINE_OFFICE = /^(?!work\/|cluster\/)[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
|
||||
const estimation = () => settings.estimation;
|
||||
const cut = (text, size) => (text.length > size ? `${text.slice(0, size - 1)}…` : text);
|
||||
const lineHolders = () => Object.fromEntries(state.claims.filter((claim) => LINE_OFFICE.test(claim.office) && claim.agent).map((claim) => [claim.office, claim.agent]));
|
||||
|
||||
function ganttDefs() {
|
||||
return s("defs", {},
|
||||
s("pattern", {id: "gantt-hatch", width: 8, height: 8, patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)"},
|
||||
s("rect", {class: "hatch-ground", width: 8, height: 8}), s("line", {class: "hatch-line", x1: 0, y1: 0, x2: 0, y2: 8})),
|
||||
s("marker", {id: "gantt-tip", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, markerUnits: "userSpaceOnUse", orient: "auto-start-reverse"},
|
||||
s("path", {class: "gantt-tip", d: "M0,0 L10,5 L0,10 z"})));
|
||||
}
|
||||
|
||||
function ganttAxis(svg, x, begin, end, height) {
|
||||
for (let index = 0; index <= GANTT.ticks; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / GANTT.ticks;
|
||||
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: GANTT.top - 6, y2: height - 4}));
|
||||
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: GANTT.top - 14, "text-anchor": index === 0 ? "start" : index === GANTT.ticks ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
|
||||
}
|
||||
}
|
||||
|
||||
function ganttMark(row, bar, x, middle) {
|
||||
const tone = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status][1] : "neutral";
|
||||
const classes = `${bar.dot ? "gantt-dot" : "gantt-bar"} tone-${tone}${bar.forecast ? " forecast" : ""}`;
|
||||
const attrs = {class: classes, "data-task": bar.task, "data-start": bar.start, "data-end": bar.end, "data-forecast": String(bar.forecast)};
|
||||
const words = s("title", {}, `${row.task}: ${bar.forecast ? "forecast " : ""}${absolute.format(stampOf(bar.start))} to ${absolute.format(stampOf(bar.end))}`);
|
||||
if (bar.dot) {
|
||||
return s("circle", {...attrs, cx: fixed(x(bar.start)), cy: fixed(middle), r: 5}, words);
|
||||
}
|
||||
return s("rect", {...attrs, x: fixed(x(bar.start)), y: fixed(middle - GANTT.bar / 2), width: fixed(Math.max(x(bar.end) - x(bar.start), 2)), height: GANTT.bar, rx: 3}, words);
|
||||
}
|
||||
|
||||
function ganttChart(rows) {
|
||||
const now = state.now;
|
||||
const drawnBars = Plan.bars(rows, state.task_edges, {now, estimation: estimation(), forecast: taskView.forecast});
|
||||
if (!drawnBars.length) {
|
||||
return emptyState("No bar", "No shown task has started, and real only draws no forecast.");
|
||||
}
|
||||
const lanes = new Map(drawnBars.map((bar, index) => [bar.task, index]));
|
||||
const bars = new Map(drawnBars.map((bar) => [bar.task, bar]));
|
||||
const begin = Math.min(now, ...drawnBars.map((bar) => bar.start));
|
||||
const end = Math.max(now, ...drawnBars.map((bar) => bar.end));
|
||||
const span = Math.max(end - begin, 3600);
|
||||
const height = GANTT.top + drawnBars.length * GANTT.lane + 10;
|
||||
const x = (ts) => GANTT.label + ((ts - begin) / span) * (GANTT.width - GANTT.label - GANTT.right);
|
||||
const middle = (task) => GANTT.top + lanes.get(task) * GANTT.lane + GANTT.lane / 2;
|
||||
const svg = s("svg", {class: "chart gantt", width: GANTT.width, height, viewBox: `0 0 ${GANTT.width} ${height}`, role: "img", "aria-label": "Gantt: one bar per task from start to end, hatched bars are forecasts, arrows lead from a task to the one that follows it"}, ganttDefs());
|
||||
ganttAxis(svg, x, begin, begin + span, height);
|
||||
const byTask = new Map(rows.map((row) => [row.task, row]));
|
||||
drawnBars.forEach((bar, index) => {
|
||||
const row = byTask.get(bar.task);
|
||||
const top = GANTT.top + index * GANTT.lane;
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top, width: GANTT.width, height: GANTT.lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 8, y: top + GANTT.lane / 2, dy: "0.35em", "data-task": row.task, on: {click: () => taskSheet(row.task)}}, s("title", {}, `${row.task}: ${titleOf(row)}`), cut(row.task, 30)));
|
||||
svg.append(ganttMark(row, bar, x, middle(bar.task)));
|
||||
});
|
||||
svg.append(s("line", {class: "now", x1: fixed(x(now)), x2: fixed(x(now)), y1: GANTT.top - 6, y2: height - 4}));
|
||||
const arrows = s("g", {class: "gantt-edges"});
|
||||
for (const edge of state.task_edges) {
|
||||
const from = bars.get(edge.from);
|
||||
const to = bars.get(edge.to);
|
||||
if (!from || !to) {
|
||||
continue;
|
||||
}
|
||||
const [x1, y1, x2, y2] = [x(from.end), middle(edge.from), x(to.start), middle(edge.to)];
|
||||
arrows.append(s("path", {class: `gantt-edge k-${edge.kind}`, "data-kind": edge.kind, "data-from": edge.from, "data-to": edge.to, d: `M${fixed(x1)},${fixed(y1)} C${fixed(x1 + 28)},${fixed(y1)} ${fixed(x2 - 28)},${fixed(y2)} ${fixed(x2)},${fixed(y2)}`, "marker-end": "url(#gantt-tip)"},
|
||||
s("title", {}, `${edge.to}: ${EDGE_WORDS[edge.kind] || edge.kind} ${edge.from}`)));
|
||||
}
|
||||
svg.append(arrows);
|
||||
const legend = h("div", {class: "legend"}, h("span", {class: "gantt-key forecast"}, "Forecast (hatched)"), Object.entries(EDGE_WORDS).map(([kind, words]) => h("span", {class: `gantt-key k-${kind}`}, words)));
|
||||
return h("div", {class: "gantt-box"}, h("div", {class: "gantt-scroll"}, svg), legend);
|
||||
}
|
||||
|
||||
function burnAxis(svg, from, to, top) {
|
||||
const x = (t) => BURN.left + ((t - from) / Math.max(to - from, 1)) * (BURN.width - BURN.left - BURN.right);
|
||||
const y = (value) => BURN.top + (1 - value / top) * (BURN.height - BURN.top - BURN.bottom);
|
||||
for (const value of [0, top / 2, top]) {
|
||||
svg.append(s("line", {class: "grid", x1: BURN.left, x2: BURN.width - BURN.right, y1: fixed(y(value)), y2: fixed(y(value))}));
|
||||
svg.append(s("text", {class: "axis", x: BURN.left - 8, y: fixed(y(value)), dy: "0.35em", "text-anchor": "end"}, String(Math.round(value * 10) / 10)));
|
||||
}
|
||||
for (const [index, t] of [from, (from + to) / 2, to].entries()) {
|
||||
svg.append(s("text", {class: "axis", x: fixed(x(t)), y: BURN.height - 14, "text-anchor": ["start", "middle", "end"][index]}, (to - from > DAY ? absolute : clock).format(stampOf(t))));
|
||||
}
|
||||
return {x, y};
|
||||
}
|
||||
|
||||
function burnChart(rows, shown) {
|
||||
const picked = Plan.scoped(rows, taskView.scope, {filtered: shown, since: state.since, until: state.until, now: state.now, holders: lineHolders()});
|
||||
const series = Plan.burn(picked.rows, {estimation: estimation(), from: picked.from, to: picked.to}).points;
|
||||
const names = estimation() === "points" ? Plan.SERIES : Plan.SERIES.filter((name) => name !== "unestimated");
|
||||
const top = Math.max(1, ...series.flatMap((point) => names.map((name) => point[name])));
|
||||
const unit = estimation() === "points" ? "story points; unestimated tasks as a line of their own" : "tasks";
|
||||
const svg = s("svg", {class: "chart burn", viewBox: `0 0 ${BURN.width} ${BURN.height}`, role: "img", "aria-label": `Burndown and burnup in ${unit}`});
|
||||
const {x, y} = burnAxis(svg, picked.from, picked.to, top);
|
||||
const last = series[series.length - 1];
|
||||
for (const name of names) {
|
||||
const d = series.map((point, index) => (index ? `H${fixed(x(point.t))} V${fixed(y(point[name]))}` : `M${fixed(x(point.t))},${fixed(y(point[name]))}`)).join(" ");
|
||||
svg.append(s("path", {class: `burn-line k-${name}`, "data-series": name, "data-last": String(last[name]), d}, s("title", {}, `${SERIES_WORDS[name]}: ${last[name]}`)));
|
||||
}
|
||||
const legend = h("div", {class: "legend"}, names.map((name) => h("span", {class: `burn-key k-${name}`, "data-series": name}, `${SERIES_WORDS[name]}: ${last[name]}`)));
|
||||
return h("div", {class: "burn-box"}, svg, legend, h("p", {class: "muted"}, `${picked.rows.length} tasks in scope, counted in ${unit}`));
|
||||
}
|
||||
|
||||
async function chooseEstimation(id) {
|
||||
if (id === estimation()) {
|
||||
return;
|
||||
}
|
||||
if (await saveSettings({...settings, estimation: id})) {
|
||||
toast(`Estimation: ${Plan.ESTIMATIONS.find(([name]) => name === id)[1]}`, [], true);
|
||||
}
|
||||
drawTasks();
|
||||
}
|
||||
|
||||
async function requestEstimates() {
|
||||
const signed = await signedProof("/api/board/sign", {action: "estimates"}, "Request estimates");
|
||||
if (signed) {
|
||||
await act("estimates", "Request estimates", {proof: signed.proof});
|
||||
}
|
||||
}
|
||||
|
||||
function sortControls() {
|
||||
const select = h("select", {class: "field", "aria-label": "Sort", on: {change: (event) => {
|
||||
taskView.sort = event.target.value;
|
||||
drawTasks();
|
||||
}}}, Plan.SORTS.map(([id, label]) => h("option", {value: id, selected: id === taskView.sort}, label)));
|
||||
const turn = (button) => relabel(button, taskView.descending ? "bi-sort-up" : "bi-sort-down", taskView.descending ? "Descending" : "Ascending", `${taskView.descending ? "Descending" : "Ascending"}: click to reverse`);
|
||||
const direction = h("button", {type: "button", class: "btn small icon-only", "data-direction": "", on: {click: (event) => {
|
||||
taskView.descending = !taskView.descending;
|
||||
turn(event.currentTarget);
|
||||
drawTasks();
|
||||
}}});
|
||||
turn(direction);
|
||||
return h("div", {class: "plan-group", role: "group", "aria-label": "Sort order"}, select, direction);
|
||||
}
|
||||
|
||||
function bulkControls() {
|
||||
const value = fibonacciPicker("Business value of the selection", 8);
|
||||
const count = counter(0);
|
||||
const button = h("button", {type: "button", class: "btn small primary", id: "bulk-value", disabled: true, ...named("Set business value", "Set the business value of every selected task as Operator, signed once with the passkey"), on: {click: () => bulkValue(Number(value.value))}},
|
||||
icon("bi-gem"), "Set business value", count);
|
||||
taskView.bulk = {button, count};
|
||||
return h("div", {class: "plan-group", role: "group", "aria-label": "Business value of the selection"}, value, button);
|
||||
}
|
||||
|
||||
function forecastControl() {
|
||||
return h("label", {class: "plan-group"}, "Forecast", h("select", {class: "field", "aria-label": "Forecast", on: {change: (event) => {
|
||||
taskView.forecast = event.target.value;
|
||||
drawTasks();
|
||||
}}}, Plan.FORECASTS.map(([id, label]) => h("option", {value: id, selected: id === taskView.forecast}, label))));
|
||||
}
|
||||
|
||||
function scopeChoices() {
|
||||
const agents = [...new Set(state.tasks.filter((row) => row.kind !== "queue").flatMap((row) => [row.worker, ...Object.keys(row.roles)]).filter((name) => name && name !== "-"))].sort((a, b) => a.localeCompare(b, "en"));
|
||||
return [["all", "Whole register"], ["filter", "List filter in the time window"], ...agents.map((name) => [`agent:${name}`, `Agent: ${name}`]), ...Object.entries(lineHolders()).map(([office, holder]) => [`line:${office}`, `Line: ${office} (${holder})`])];
|
||||
}
|
||||
|
||||
function scopeControl() {
|
||||
const choices = scopeChoices();
|
||||
if (!choices.some(([id]) => id === taskView.scope)) {
|
||||
taskView.scope = "all";
|
||||
}
|
||||
return h("label", {class: "plan-group"}, "Scope", h("select", {class: "field", "aria-label": "Scope", on: {change: (event) => {
|
||||
taskView.scope = event.target.value;
|
||||
drawTasks();
|
||||
}}}, choices.map(([id, label]) => h("option", {value: id, selected: id === taskView.scope}, label))));
|
||||
}
|
||||
|
||||
function planBar(view) {
|
||||
const modes = h("div", {class: "segmented", role: "group", "aria-label": "Estimation"}, Plan.ESTIMATIONS.map(([id, label]) => h("button", {type: "button", "data-estimation": id, "aria-pressed": String(estimation() === id), title: `${label}: ${id === "points" ? "the Fibonacci complexity of rasci.py estimate" : "every task counts 1"}`, on: {click: () => chooseEstimation(id)}}, label)));
|
||||
const request = h("button", {type: "button", class: "btn small", id: "request-estimates", hidden: estimation() !== "points", ...named("Request estimates", "Request estimates: one newsletter asks every agent with a RASCI role in an unestimated task for business value and complexity"), on: {click: requestEstimates}}, icon("bi-send-check"), "Request estimates");
|
||||
const own = {list: () => [sortControls(), bulkControls()], gantt: forecastControl, burn: scopeControl}[view];
|
||||
taskView.plan = {modes, request};
|
||||
return h("div", {class: "card controls plan-bar", role: "toolbar", "aria-label": "Plan"}, modes, request, own ? own() : null);
|
||||
}
|
||||
|
||||
function syncPlan() {
|
||||
const plan = taskView.plan;
|
||||
if (!plan) {
|
||||
return;
|
||||
}
|
||||
for (const button of plan.modes.querySelectorAll("button")) {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.estimation === estimation()));
|
||||
}
|
||||
plan.request.hidden = estimation() !== "points";
|
||||
if (taskView.bulk) {
|
||||
const picked = state.tasks.filter((row) => row.editable && taskView.picked.has(row.task)).length;
|
||||
taskView.bulk.count.textContent = String(picked);
|
||||
taskView.bulk.button.disabled = picked === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function pickTask(task, on) {
|
||||
if (on) {
|
||||
taskView.picked.add(task);
|
||||
} else {
|
||||
taskView.picked.delete(task);
|
||||
}
|
||||
syncPlan();
|
||||
}
|
||||
|
||||
async function bulkValue(value) {
|
||||
const rows = state.tasks.filter((row) => row.editable && taskView.picked.has(row.task));
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
const changes = rows.map((row) => ({task: row.task, argv: ["estimate", row.task, ESTIMATOR, "--value", String(value)]}));
|
||||
if (await changeTasks(changes, `Business value ${value} for ${rows.length} tasks`, null)) {
|
||||
taskView.picked.clear();
|
||||
taskView.sig = "";
|
||||
drawTasks();
|
||||
}
|
||||
}
|
||||
@@ -8,78 +8,94 @@
|
||||
return h("select", {class: "field", "aria-label": label}, FIBONACCI.map((value) => h("option", {value, selected: value === picked}, String(value))));
|
||||
}
|
||||
|
||||
async function changeTask(row, argv, label, outcome) {
|
||||
const items = [{kind: "task", id: row.task, argv}];
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Signiere mit dem Passkey …"));
|
||||
async function changeTasks(changes, label, outcome) {
|
||||
const items = changes.map(({task, argv}) => ({kind: "task", id: task, argv}));
|
||||
const tell = (attrs, text) => (outcome ? outcome.replaceChildren(h("p", {class: "muted", ...attrs}, text)) : null);
|
||||
tell({}, "Signing with the passkey …");
|
||||
const signed = await proofFor(items, label);
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
return;
|
||||
tell({}, "Not signed: nothing was handed over.");
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/tasks", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return;
|
||||
toast(`${label}: not possible`, fail(result), false);
|
||||
tell({}, `${result.status} ${result.text}`.trim());
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signiert und an den Runner übergeben (Job ${result.data.jobs.join(", ")}); er ruft rasci.py auf dem Host auf.`));
|
||||
toast(`${label}: an den Runner übergeben`, [shownArgv(argv)], true);
|
||||
tell({"data-jobs": result.data.jobs.join(" ")}, `Signed and handed to the runner (job ${result.data.jobs.join(", ")}); it calls rasci.py on the host.`);
|
||||
toast(`${label}: handed to the runner`, changes.map(({argv}) => shownArgv(argv)), true);
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
|
||||
function changeTask(row, argv, label, outcome) {
|
||||
return changeTasks([{task: row.task, argv}], label, outcome);
|
||||
}
|
||||
|
||||
function priorityForm(row) {
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const own = row.estimates[ESTIMATOR] || null;
|
||||
const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priorität", placeholder: "1"});
|
||||
const value = fibonacciPicker("Geschäftswert", own ? own.value : 5);
|
||||
const complexity = fibonacciPicker("Komplexität", own ? own.complexity : 3);
|
||||
const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priority", placeholder: "1"});
|
||||
const value = fibonacciPicker("Business value", own ? own.value : 5);
|
||||
const complexity = fibonacciPicker("Complexity", own && own.complexity ? own.complexity : 3);
|
||||
const alone = fibonacciPicker("Business value as Operator", own ? own.value : 8);
|
||||
const pinCode = h("code", {});
|
||||
const estimateCode = h("code", {});
|
||||
const valueCode = h("code", {});
|
||||
const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)];
|
||||
const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value];
|
||||
const pin = h("button", {type: "button", class: "btn small primary", ...named("Anheften", "Anheften: Priorität mit dem Passkey signieren")}, icon("bi-pin-angle-fill"), "Anheften");
|
||||
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Schätzung speichern", "Schätzung signiert an den Runner geben")}, icon("bi-calculator"), "Schätzung speichern");
|
||||
const valueArgv = () => ["estimate", row.task, ESTIMATOR, "--value", alone.value];
|
||||
const pin = h("button", {type: "button", class: "btn small primary", ...named("Pin", "Pin: sign the priority with the passkey")}, icon("bi-pin-angle-fill"), "Pin");
|
||||
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Save estimate", "Hand the estimate to the runner, signed")}, icon("bi-calculator"), "Save estimate");
|
||||
const setValue = h("button", {type: "button", class: "btn small primary", ...named("Set business value", "Set the business value as Operator, signed with the passkey; WSJF uses it instead of the median")}, icon("bi-gem"), "Set business value");
|
||||
const sync = () => {
|
||||
pin.disabled = !prioText(prio.value);
|
||||
pinCode.textContent = pin.disabled ? `Priorität 1 bis ${PRIO_MAX} eingeben` : shownArgv(prioArgv());
|
||||
pinCode.textContent = pin.disabled ? `Enter a priority from 1 to ${PRIO_MAX}` : shownArgv(prioArgv());
|
||||
estimateCode.textContent = shownArgv(estimateArgv());
|
||||
valueCode.textContent = shownArgv(valueArgv());
|
||||
};
|
||||
for (const field of [prio, value, complexity]) {
|
||||
for (const field of [prio, value, complexity, alone]) {
|
||||
field.addEventListener("input", sync);
|
||||
}
|
||||
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priorität ${row.task}`, outcome));
|
||||
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priority ${row.task}`, outcome));
|
||||
estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome));
|
||||
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Schätzung verwerfen", "Schätzung verwerfen: deine Schätzung mit dem Passkey signiert löschen"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Schätzung verwerfen") : null;
|
||||
setValue.addEventListener("click", () => changeTask(row, valueArgv(), `Business value ${row.task}`, outcome));
|
||||
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Discard estimate", "Discard estimate: delete your estimate, signed with the passkey"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Discard estimate") : null;
|
||||
sync();
|
||||
return h("section", {class: "task-edit", "aria-label": "Priorität und WSJF"},
|
||||
h("h3", {}, "Priorität anheften"),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Priorität (1 zuerst)", prio), pin),
|
||||
return h("section", {class: "task-edit", "aria-label": "Priority and WSJF"},
|
||||
h("h3", {}, "Pin priority"),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Priority (1 first)", prio), pin),
|
||||
h("div", {class: "command"}, pinCode),
|
||||
h("h3", {}, `WSJF schätzen als ${ESTIMATOR}`),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Geschäftswert", value), h("label", {}, "Komplexität", complexity), estimate, drop),
|
||||
h("h3", {}, `Estimate WSJF as ${ESTIMATOR}`),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Business value", value), h("label", {}, "Complexity", complexity), estimate, drop),
|
||||
h("div", {class: "command"}, estimateCode),
|
||||
h("p", {class: "muted"}, "Dein Passkey signiert genau diesen Aufruf; der Runner auf dem Host prüft die Signatur und die Form der Argumente und startet rasci.py ohne Shell. Die Priorität der Agenten bleibt unberührt."),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Signieren geht nur unter ", localLink()),
|
||||
h("h3", {}, `Business value as ${ESTIMATOR}`),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Business value", alone), setValue),
|
||||
h("div", {class: "command"}, valueCode),
|
||||
h("p", {class: "muted"}, "WSJF uses your business value instead of the median of the agents' values."),
|
||||
h("p", {class: "muted"}, "Your passkey signs exactly this call; the runner on the host checks the signature and the shape of the arguments and starts rasci.py without a shell. The agent priority stays untouched."),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Signing works only under ", localLink()),
|
||||
outcome);
|
||||
}
|
||||
|
||||
function taskRoles(row) {
|
||||
const entries = Object.entries(row.roles);
|
||||
return [h("h3", {}, "RASCI", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "Keine Rollen eingetragen.")];
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "No roles entered.")];
|
||||
}
|
||||
|
||||
function taskEstimates(row) {
|
||||
const entries = Object.entries(row.estimates);
|
||||
const score = row.wsjf;
|
||||
return [h("h3", {}, "WSJF-Schätzungen", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Geschäftswert ${entry.value} · Komplexität ${entry.complexity}`))))) : h("p", {class: "muted"}, "Noch keine Schätzung: die Aufgabe rangiert nach den geschätzten."),
|
||||
score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null];
|
||||
return [h("h3", {}, "WSJF estimates", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Business value ${entry.value} · complexity ${dash(entry.complexity)}`))))) : h("p", {class: "muted"}, "No estimate yet: the task ranks after the estimated ones."),
|
||||
score ? h("p", {class: "muted"}, `${Object.hasOwn(row.estimates, ESTIMATOR) ? `${ESTIMATOR}'s business value` : "Median business value"} ${score.value} / median complexity ${score.complexity} = WSJF ${score.score}`) : null];
|
||||
}
|
||||
|
||||
function taskJobs(row) {
|
||||
const jobs = state.jobs.filter((job) => job.kind === "task" && job.task === row.task).slice(-5).reverse();
|
||||
return jobs.length ? [h("h3", {}, "Signierte Änderungen", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
|
||||
return jobs.length ? [h("h3", {}, "Signed changes", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
|
||||
h("div", {class: "row"}, h("code", {}, job.command), status(job.status)),
|
||||
job.status === "refused" || job.status === "failed" ? h("pre", {class: "notes"}, job.output) : null)))] : null;
|
||||
}
|
||||
@@ -89,16 +105,16 @@
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const facts = [["Ziel", row.objective], ["Key Results", row.total ? `${row.met} von ${row.total} erfüllt` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
|
||||
const facts = [["Objective", row.objective], ["Key Results", row.total ? `${row.met} of ${row.total} met` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
|
||||
openSheet(row.task, [
|
||||
h("div", {class: "who"}, taskBadges(row, true), row.worker !== "-" ? [face(row.worker, false), person(row.worker)] : null),
|
||||
h("p", {class: "text"}, titleOf(row)),
|
||||
meta(row.queue ? [["Queue", `${row.queue.kind} für ${row.queue.parent}`], ...facts.slice(2)] : facts),
|
||||
meta(row.queue ? [["Queue", `${row.queue.kind} for ${row.queue.parent}`], ...facts.slice(2)] : facts),
|
||||
workChips(row.open_work),
|
||||
flowDetails(row.flow, branchOf(row)),
|
||||
taskRoles(row),
|
||||
row.kind === "task" ? taskEstimates(row) : null,
|
||||
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Beendet: Priorität und WSJF bleiben, wie sie sind." : "Priorität und WSJF gibt es nur für Aufgaben des Registers, nicht für Befehle, Hotfixes und Queue-Jobs."),
|
||||
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Ended: priority and WSJF stay as they are." : "Priority and WSJF exist only for tasks of the register, not for commands, hotfixes and queue jobs."),
|
||||
taskJobs(row),
|
||||
], false);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]};
|
||||
const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]];
|
||||
const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]};
|
||||
const TASK_STATUS = {queued: ["waiting", "info"], running: ["running", "ok"], blocked: ["blocked", "bad"], done: ["done", "neutral"], stopped: ["stopped", "neutral"], awaiting: ["waiting for a grant", "warn"]};
|
||||
const TASK_COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["blocked", "Blocked"], ["done", "Done"], ["stopped", "Stopped"]];
|
||||
const TASK_KINDS = {command: ["Command", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue job", "teal"]};
|
||||
const TASK_FILTERS = [
|
||||
["alle", "Alle", "bi-list-check", () => true],
|
||||
["offen", "Offen", "bi-play-circle", (row) => !row.ended],
|
||||
["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0],
|
||||
["operator", "Wartet auf Operator", "bi-person-raised-hand", null],
|
||||
["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
|
||||
["beendet", "Beendet", "bi-check2-circle", (row) => row.ended],
|
||||
["all", "All", "bi-list-check", () => true],
|
||||
["open", "Open", "bi-play-circle", (row) => !row.ended],
|
||||
["work", "Open work", "bi-pencil-square", (row) => row.open_work.length > 0],
|
||||
["operator", "Waits on Operator", "bi-person-raised-hand", null],
|
||||
["grant", "Waiting for a grant", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
|
||||
["ended", "Ended", "bi-check2-circle", (row) => row.ended],
|
||||
];
|
||||
const ROLE_TONES = {R: "ok", A: "violet", S: "info", C: "warn", I: "neutral"};
|
||||
const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"};
|
||||
const MATRIX_PARTIES = 40;
|
||||
const taskView = {filter: "alle", q: "", dom: null, sig: ""};
|
||||
const taskView = {filter: "all", q: "", dom: null, sig: "", sort: "prio", descending: false, forecast: "always", scope: "all", picked: new Set(), plan: null, bulk: null};
|
||||
|
||||
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
|
||||
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
|
||||
@@ -31,9 +31,9 @@
|
||||
kindName ? pill(kindName, kindTone) : null,
|
||||
withStatus ? taskStatus(row) : null,
|
||||
row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null,
|
||||
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "angeheftete Priorität"}, `Prio ${row.prio}`) : null,
|
||||
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null,
|
||||
openJobs(row.task).length ? pill("Änderung beim Runner", "warn") : null,
|
||||
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "pinned priority"}, `Prio ${row.prio}`) : null,
|
||||
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: business value ${score.value} / complexity ${score.complexity}`}, `WSJF ${score.score}`) : null,
|
||||
openJobs(row.task).length ? pill("Change at the runner", "warn") : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,23 +44,24 @@
|
||||
|
||||
function taskPlace(row) {
|
||||
if (row.kind === "queue") {
|
||||
return h("span", {}, `${row.queue.kind} für ${row.queue.parent} · eingereiht `, when(row.queue.enqueued));
|
||||
return h("span", {}, `${row.queue.kind} for ${row.queue.parent} · queued `, when(row.queue.enqueued));
|
||||
}
|
||||
const place = row.worktree || (row.workdir !== "-" ? row.workdir : "");
|
||||
return place ? h("code", {title: row.repo || place}, place) : null;
|
||||
}
|
||||
|
||||
function opener(row) {
|
||||
return h("button", {type: "button", class: "linkish", title: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
|
||||
return h("button", {type: "button", class: "linkish", title: `Open ${row.task}`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
|
||||
}
|
||||
|
||||
function taskItem(row) {
|
||||
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
|
||||
const pick = row.editable ? h("input", {type: "checkbox", class: "task-pick", "aria-label": `Select ${row.task}`, title: `Select ${row.task} for a bulk business value`, checked: taskView.picked.has(row.task), on: {change: (event) => pickTask(row.task, event.target.checked)}}) : null;
|
||||
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
|
||||
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
|
||||
row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null),
|
||||
h("div", {class: "head-row"}, pick, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
|
||||
row.editable ? iconBtn("bi-sort-numeric-down", "Priority", "btn small soft", () => taskSheet(row.task), `Priority and WSJF of ${row.task}: pin or estimate, signed with the passkey`) : null),
|
||||
opener(row),
|
||||
row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${row.objective}`) : null,
|
||||
row.objective !== "-" ? h("div", {class: "muted"}, `Objective: ${row.objective}`) : null,
|
||||
h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)),
|
||||
flowLine(row.flow, branchOf(row)),
|
||||
workChips(row.open_work));
|
||||
@@ -82,7 +83,7 @@
|
||||
const cards = rows.filter((row) => columnOf(row) === id);
|
||||
return h("section", {class: "card task-column", "data-column": id, "aria-label": label},
|
||||
h("h3", {class: "group-title"}, label, counter(cards.length)),
|
||||
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer"));
|
||||
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "empty"));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -91,21 +92,21 @@
|
||||
}
|
||||
|
||||
function taskMatrix(rows) {
|
||||
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "de"));
|
||||
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "en"));
|
||||
if (!parties.length) {
|
||||
return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder I.");
|
||||
return emptyState("No roles", "None of the shown tasks names R, A, S, C or I.");
|
||||
}
|
||||
const columns = parties.slice(0, MATRIX_PARTIES);
|
||||
const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)),
|
||||
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} weitere Parteien nicht gezeigt`) : null);
|
||||
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
|
||||
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} more parties not shown`) : null);
|
||||
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Task"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
|
||||
const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null},
|
||||
h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))),
|
||||
columns.map((party) => {
|
||||
const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : "";
|
||||
return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters));
|
||||
}));
|
||||
const table = h("table", {class: "matrix", "aria-label": "RASCI-Matrix: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line)));
|
||||
const table = h("table", {class: "matrix", "aria-label": "RASCI matrix: tasks by parties"}, head, h("tbody", {}, rows.map(line)));
|
||||
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@
|
||||
}
|
||||
|
||||
function taskViewSwitch(inbox) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "View"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
|
||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
@@ -139,16 +140,18 @@
|
||||
function tasksScreen() {
|
||||
const inbox = ui.screen === "inbox";
|
||||
const view = ui.views.tasks;
|
||||
const chips = h("div", {class: "chips", role: "group", "aria-label": "Aufgabenfilter"});
|
||||
const chips = h("div", {class: "chips", role: "group", "aria-label": "Task filters"});
|
||||
const count = h("span", {class: "muted"});
|
||||
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => {
|
||||
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Filter by task, title, worker, branch …", value: taskView.q, "aria-label": "Filter tasks", on: {input: (event) => {
|
||||
taskView.q = event.target.value;
|
||||
drawTasks();
|
||||
}}});
|
||||
const body = inbox ? inboxScreen() : h("div", {class: "tasks-body"});
|
||||
taskView.dom = {chips, count, body, inbox, view};
|
||||
taskView.sig = "";
|
||||
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), chips, body);
|
||||
taskView.plan = null;
|
||||
taskView.bulk = null;
|
||||
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), inbox ? null : planBar(view), choiceBar(), chips, body);
|
||||
}
|
||||
|
||||
function drawTasks() {
|
||||
@@ -157,21 +160,34 @@
|
||||
return;
|
||||
}
|
||||
const q = taskView.q.trim().toLowerCase();
|
||||
const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
|
||||
const sig = JSON.stringify([state.tasks, state.jobs, state.task_edges, settings.estimation, taskView.filter, taskView.sort, taskView.descending, taskView.forecast, taskView.scope, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
|
||||
if (sig === taskView.sig) {
|
||||
return;
|
||||
}
|
||||
taskView.sig = sig;
|
||||
const rows = state.tasks;
|
||||
const editable = new Set(rows.filter((row) => row.editable).map((row) => row.task));
|
||||
for (const task of [...taskView.picked]) {
|
||||
if (!editable.has(task)) {
|
||||
taskView.picked.delete(task);
|
||||
}
|
||||
}
|
||||
syncPlan();
|
||||
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
|
||||
if (dom.inbox) {
|
||||
dom.count.textContent = `${rows.length} Aufgaben im Register`;
|
||||
dom.count.textContent = `${rows.length} tasks in the register`;
|
||||
return;
|
||||
}
|
||||
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
|
||||
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
|
||||
const shown = rows.filter(test).filter(hit);
|
||||
dom.count.textContent = `${shown.length} von ${rows.length} Aufgaben`;
|
||||
const views = {list: (found) => h("ol", {class: "task-list"}, found.map(taskItem)), board: taskBoard, rasci: taskMatrix};
|
||||
dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter."));
|
||||
dom.count.textContent = `${shown.length} of ${rows.length} tasks`;
|
||||
const views = {
|
||||
list: (found) => h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map(taskItem)),
|
||||
board: taskBoard,
|
||||
rasci: taskMatrix,
|
||||
gantt: ganttChart,
|
||||
burn: (found) => burnChart(rows, found),
|
||||
};
|
||||
dom.body.replaceChildren(shown.length || dom.view === "burn" ? views[dom.view](shown) : emptyState("No task", "No entry matches this filter."));
|
||||
}
|
||||
@@ -13,14 +13,14 @@
|
||||
};
|
||||
|
||||
function customRange() {
|
||||
const from = h("input", {type: "datetime-local", "aria-label": "Von", value: localInput(ui.from === null ? state.since : ui.from)});
|
||||
const to = h("input", {type: "datetime-local", "aria-label": "Bis", value: localInput(ui.to === null ? state.until : ui.to)});
|
||||
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "bis"), to,
|
||||
const from = h("input", {type: "datetime-local", "aria-label": "From", value: localInput(ui.from === null ? state.since : ui.from)});
|
||||
const to = h("input", {type: "datetime-local", "aria-label": "To", value: localInput(ui.to === null ? state.until : ui.to)});
|
||||
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "to"), to,
|
||||
h("button", {type: "button", class: "btn small soft", on: {click: () => {
|
||||
const begin = Date.parse(from.value) / 1000;
|
||||
const end = Date.parse(to.value) / 1000;
|
||||
if (!Number.isFinite(begin) || !Number.isFinite(end) || begin >= end) {
|
||||
toast("Zeitraum ungültig", ["„Von“ muss vor „Bis“ liegen."], false);
|
||||
toast("Time range invalid", ["“From” must come before “To”."], false);
|
||||
return;
|
||||
}
|
||||
stopReplay();
|
||||
@@ -28,15 +28,15 @@
|
||||
ui.from = begin;
|
||||
ui.to = end;
|
||||
reload();
|
||||
}}}, "Anwenden"));
|
||||
}}}, "Apply"));
|
||||
}
|
||||
|
||||
function timeBar() {
|
||||
const bar = document.getElementById("time");
|
||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
||||
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
|
||||
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
|
||||
const play = iconBtn("bi-play-fill", "Wiedergabe", "btn small soft", null);
|
||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Time range"});
|
||||
const summary = h("summary", {title: "Time range and replay"});
|
||||
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Replay position"});
|
||||
const play = iconBtn("bi-play-fill", "Replay", "btn small soft", null);
|
||||
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
||||
const label = h("span", {class: "window-label"});
|
||||
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
reload();
|
||||
});
|
||||
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Zeitraum"), presets, h("h3", {}, "Wiedergabe"), h("div", {class: "replay"}, play, slider, liveButton), label,
|
||||
h("p", {class: "muted"}, "Einen eigenen Zeitraum setzt Filter ▾ im Stream.")));
|
||||
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Time range"), presets, h("h3", {}, "Replay"), h("div", {class: "replay"}, play, slider, liveButton), label,
|
||||
h("p", {class: "muted"}, "Filter ▾ in the stream sets a custom time range.")));
|
||||
timeDom = {summary, presets, slider, play, live: liveButton, label};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
for (const button of timeDom.presets.children) {
|
||||
button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset));
|
||||
}
|
||||
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "eigener"])[1]}${ui.asOf === null ? "" : " · Wiedergabe"} ▾`;
|
||||
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "custom"])[1]}${ui.asOf === null ? "" : " · replay"} ▾`;
|
||||
timeDom.slider.disabled = ui.preset === -1;
|
||||
timeDom.play.disabled = ui.preset === -1;
|
||||
timeDom.live.disabled = live();
|
||||
@@ -127,6 +127,6 @@
|
||||
ui.playing = false;
|
||||
window.clearTimeout(replay);
|
||||
if (timeDom) {
|
||||
relabel(timeDom.play, "bi-play-fill", "Wiedergabe");
|
||||
relabel(timeDom.play, "bi-play-fill", "Replay");
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
function branchStates(entry) {
|
||||
return [
|
||||
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `gemergt${entry.merged_by ? ` durch ${entry.merged_by}` : ""}${entry.merged_into ? ` in ${entry.merged_into}` : ""}`, "ok") : null,
|
||||
entry.state === "unmerged" ? chip("bi-signpost-split", "nicht gemergt", "neutral") : null,
|
||||
entry.state === "protected" ? chip("bi-shield-lock", "geschützt", "violet") : null,
|
||||
entry.state === "stale" ? chip("bi-hourglass-bottom", "veraltet", "warn") : null,
|
||||
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} Commits ungepusht`, "info", entry.ahead) : null,
|
||||
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} Commits zurück`, "warn", entry.behind) : null,
|
||||
entry.gone ? chip("bi-cloud-slash", "Upstream weg", "bad") : null,
|
||||
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "ohne Upstream", "neutral") : null,
|
||||
entry.skip ? chip("bi-slash-circle", `gc überspringt: ${entry.skip}`, "bad") : null,
|
||||
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `merged${entry.merged_by ? ` by ${entry.merged_by}` : ""}${entry.merged_into ? ` into ${entry.merged_into}` : ""}`, "ok") : null,
|
||||
entry.state === "unmerged" ? chip("bi-signpost-split", "not merged", "neutral") : null,
|
||||
entry.state === "protected" ? chip("bi-shield-lock", "protected", "violet") : null,
|
||||
entry.state === "stale" ? chip("bi-hourglass-bottom", "stale", "warn") : null,
|
||||
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} commits unpushed`, "info", entry.ahead) : null,
|
||||
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} commits behind`, "warn", entry.behind) : null,
|
||||
entry.gone ? chip("bi-cloud-slash", "upstream gone", "bad") : null,
|
||||
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "no upstream", "neutral") : null,
|
||||
entry.skip ? chip("bi-slash-circle", `gc skips: ${entry.skip}`, "bad") : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
const counts = [wt.staged, wt.unstaged, wt.untracked];
|
||||
const changed = counts.every((value) => typeof value === "number") ? counts.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
return [
|
||||
wt.state === "clean" ? chip("bi-check-circle", "sauber", "ok") : null,
|
||||
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "nicht committete Änderungen" : `nicht committet: ${wt.staged} gestaged, ${wt.unstaged} geändert, ${wt.untracked} neu`, "warn", changed) : null,
|
||||
wt.state === "missing" ? chip("bi-question-circle", "Worktree fehlt", "neutral") : null,
|
||||
wt.locked ? chip("bi-lock-fill", `gesperrt: ${wt.locked}`, "warn") : null,
|
||||
wt.state === "clean" ? chip("bi-check-circle", "clean", "ok") : null,
|
||||
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "uncommitted changes" : `not committed: ${wt.staged} staged, ${wt.unstaged} changed, ${wt.untracked} new`, "warn", changed) : null,
|
||||
wt.state === "missing" ? chip("bi-question-circle", "worktree missing", "neutral") : null,
|
||||
wt.locked ? chip("bi-lock-fill", `locked: ${wt.locked}`, "warn") : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
return null;
|
||||
}
|
||||
const ready = tree.data.delete_ready === true;
|
||||
const hint = ready ? `Löschen: ${entry.name} lokal entfernen, nach Trockenlauf und Signatur` : `Löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}`;
|
||||
const button = iconBtn("bi-trash3", "Löschen", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
|
||||
const hint = ready ? `Delete: remove ${entry.name} locally, after dry run and signature` : `Delete blocked: ${tree.data.delete_reason || "delete_ready is false"}`;
|
||||
const button = iconBtn("bi-trash3", "Delete", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
|
||||
button.disabled = !ready;
|
||||
return h("span", {class: "hint", title: hint}, button);
|
||||
}
|
||||
@@ -39,19 +39,19 @@
|
||||
function deleteAll() {
|
||||
const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
|
||||
const ready = tree.data.delete_ready === true && items.length > 0;
|
||||
const hint = tree.data.delete_ready !== true ? `Alle gemergten löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}` : items.length ? `Alle gemergten löschen: ${items.length} Branches nach Trockenlauf und Signatur` : "Alle gemergten löschen: kein gemergter Branch ohne Skip-Grund";
|
||||
const button = iconBtn("bi-trash3-fill", "Alle gemergten löschen", "btn small danger", () => deleteDialog(items), hint);
|
||||
const hint = tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason";
|
||||
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items), hint);
|
||||
button.disabled = !ready;
|
||||
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null);
|
||||
}
|
||||
|
||||
function leafRow(found) {
|
||||
return h("li", {class: "leaf"}, h("span", {class: "state", "data-tone": found.ended ? "warn" : "neutral", role: "img", ...named(found.kind)}, icon(ARTEFACT_ICONS[found.kind] || "bi-paperclip")),
|
||||
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
|
||||
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("task ended", "warn") : null);
|
||||
}
|
||||
|
||||
function openWork(wt) {
|
||||
return wt.open_work.length ? chip("bi-lightning-charge", `offene Arbeit: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
|
||||
return wt.open_work.length ? chip("bi-lightning-charge", `open work: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
|
||||
}
|
||||
|
||||
function worktreeRow(wt) {
|
||||
@@ -61,11 +61,11 @@
|
||||
h("span", {class: "states"}, worktreeStates(wt), openWork(wt)),
|
||||
h("code", {}, wt.path),
|
||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "nobody"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate green" : "Gate red", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
copyButton(wt.path)),
|
||||
flowLine(wt.flow, wt.branch),
|
||||
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
|
||||
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artifacts"}, wt.artefacts.map(leafRow)) : null);
|
||||
}
|
||||
|
||||
function resultOf(repo, entry) {
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
function resultChip(result) {
|
||||
const [name, label, tone] = result.status === "done" ? ["bi-check2-all", "gelöscht", "ok"] : result.status === "refused" ? ["bi-x-octagon", "verweigert", "bad"] : ["bi-exclamation-triangle", "fehlgeschlagen", "bad"];
|
||||
const [name, label, tone] = result.status === "done" ? ["bi-check2-all", "deleted", "ok"] : result.status === "refused" ? ["bi-x-octagon", "refused", "bad"] : ["bi-exclamation-triangle", "failed", "bad"];
|
||||
return chip(name, `${label}${result.reason ? `: ${result.reason}` : ""}`, tone);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
if (!tree.data || tree.data.error) {
|
||||
tree.dom.count.textContent = "";
|
||||
tree.dom.removeAll.replaceChildren();
|
||||
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
|
||||
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
|
||||
return;
|
||||
}
|
||||
tree.dom.removeAll.replaceChildren(deleteAll());
|
||||
@@ -110,9 +110,9 @@
|
||||
const tasks = (tree.data.tasks || []).filter((task) => hit(task.task, task.title, ...task.artefacts.map((found) => found.ref)));
|
||||
const listed = new Set(tree.data.repos.flatMap((repo) => repo.branches.map((entry) => `${repo.path}\u0000${entry.name}`)));
|
||||
const gone = (tree.data.cleanups || []).flatMap((result) => result.items).filter((item) => !listed.has(`${item.repository}\u0000${item.branch}`) && hit(item.repository, item.branch));
|
||||
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Löschen gesperrt: ${tree.data.delete_reason}`}`;
|
||||
tree.dom.count.textContent = `${repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Delete blocked: ${tree.data.delete_reason}`}`;
|
||||
if (!repos.length && !tasks.length && !gone.length) {
|
||||
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
|
||||
tree.dom.list.replaceChildren(emptyState("Nothing found", "No repository matches the filter."));
|
||||
return;
|
||||
}
|
||||
const cards = repos.map(({repo, branches, detached}) => {
|
||||
@@ -121,11 +121,11 @@
|
||||
const merged = repo.branches.filter((entry) => entry.cleanup).length;
|
||||
const busy = trees.filter((wt) => wt.open_work.length).length;
|
||||
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
|
||||
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null,
|
||||
busy ? chip("bi-lightning-charge", `${busy} Worktrees mit offener Arbeit`, "warn", busy) : null),
|
||||
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} branches`), counter(`${trees.length} worktrees`),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} worktrees with changes`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} merged, deletable`, "ok", merged) : null,
|
||||
busy ? chip("bi-lightning-charge", `${busy} worktrees with open work`, "warn", busy) : null),
|
||||
branches.map((entry) => branchRow(repo, entry)),
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "detached HEAD")), detached.map(worktreeRow)) : null);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
tree.open.add(repo.path);
|
||||
@@ -135,10 +135,10 @@
|
||||
});
|
||||
return details;
|
||||
});
|
||||
const loose = tasks.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-card-checklist"), h("span", {}, "Aufgaben ohne Worktree"), counter(tasks.length)),
|
||||
const loose = tasks.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-card-checklist"), h("span", {}, "Tasks without a worktree"), counter(tasks.length)),
|
||||
tasks.map((task) => h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, task.task), h("span", {class: "muted"}, task.title), status(task.status)),
|
||||
h("ul", {class: "leaves", "aria-label": "Artefakte"}, task.artefacts.map(leafRow))))) : null;
|
||||
const past = gone.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-trash3"), h("span", {}, "Zuletzt gelöscht"), counter(gone.length)),
|
||||
h("ul", {class: "leaves", "aria-label": "Artifacts"}, task.artefacts.map(leafRow))))) : null;
|
||||
const past = gone.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-trash3"), h("span", {}, "Recently deleted"), counter(gone.length)),
|
||||
gone.map((item) => h("div", {class: "branch-row", "data-result": item.status}, h("div", {class: "branch-name"}, h("strong", {}, item.branch), h("code", {}, item.repository), resultChip(item))))) : null;
|
||||
tree.dom.list.replaceChildren(...cards, loose || "", past || "");
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
const Plan = (() => {
|
||||
"use strict";
|
||||
const FORECASTS = [["always", "always"], ["points", "points only"], ["real", "real only"]];
|
||||
const ESTIMATIONS = [["none", "No estimate"], ["points", "Story points"]];
|
||||
const SORTS = [["prio", "Prio"], ["wsjf", "WSJF"], ["value", "Business value"], ["complexity", "Complexity"], ["status", "Status"], ["start", "Start"], ["end", "End"], ["agent", "Agent"], ["title", "Title"]];
|
||||
const STATUS_ORDER = ["queued", "awaiting", "claimed", "running", "blocked", "stale", "done", "stopped"];
|
||||
const SERIES = ["scope", "done", "remaining", "unestimated"];
|
||||
const OUTSIDE = ["queue", "command"];
|
||||
|
||||
function median(values) {
|
||||
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
if (!sorted.length) {
|
||||
return null;
|
||||
}
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - task rows with status, start, end (epoch seconds or null) and points (median complexity or null).
|
||||
* @returns {duration, perPoint}: the median seconds a done task took from start to end, and the median seconds
|
||||
* per point of the done tasks with points; null where no done task gives one.
|
||||
*/
|
||||
function medians(rows) {
|
||||
const done = rows.filter((row) => row.status === "done" && row.start !== null && row.end !== null && row.end >= row.start);
|
||||
return {
|
||||
duration: median(done.map((row) => row.end - row.start)),
|
||||
perPoint: median(done.filter((row) => row.points).map((row) => (row.end - row.start) / row.points)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param row - a task row not yet started.
|
||||
* @param estimation - "none" or "points".
|
||||
* @param forecast - "always" or "points" (points only).
|
||||
* @param known - medians() of the rows.
|
||||
* @returns the forecast length in seconds: points times the median seconds per point when story points apply,
|
||||
* else under always the median duration of done tasks; 0 (a dot) where neither applies.
|
||||
*/
|
||||
function forecastLength(row, estimation, forecast, known) {
|
||||
if (estimation === "points" && row.points && known.perPoint !== null) {
|
||||
return row.points * known.perPoint;
|
||||
}
|
||||
return forecast === "always" && known.duration !== null ? known.duration : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - task rows in their shown order.
|
||||
* @param edges - {from, to, kind} edges; only those between the rows count.
|
||||
* @param options - {now, estimation, forecast}.
|
||||
* @returns one {task, start, end, forecast, dot} per drawn row: a started task from start to its end, an open one
|
||||
* to now; a task not yet started, unless forecast is "real", as a forecast behind the latest end of its
|
||||
* predecessors and now, forecastLength() long, a dot when 0. A task never started is left out under "real", an
|
||||
* ended one without start and end always.
|
||||
*/
|
||||
function bars(rows, edges, options) {
|
||||
const known = medians(rows);
|
||||
const byTask = new Map(rows.map((row) => [row.task, row]));
|
||||
const before = new Map(rows.map((row) => [row.task, []]));
|
||||
for (const edge of edges) {
|
||||
if (byTask.has(edge.from) && before.has(edge.to) && edge.from !== edge.to) {
|
||||
before.get(edge.to).push(edge.from);
|
||||
}
|
||||
}
|
||||
const placed = new Map();
|
||||
const visiting = new Set();
|
||||
const place = (row) => {
|
||||
if (placed.has(row.task)) {
|
||||
return placed.get(row.task);
|
||||
}
|
||||
if (visiting.has(row.task)) {
|
||||
return null;
|
||||
}
|
||||
visiting.add(row.task);
|
||||
let bar = null;
|
||||
if (row.start !== null) {
|
||||
const end = row.end !== null ? row.end : row.ended ? row.start : Math.max(options.now, row.start);
|
||||
bar = {task: row.task, start: row.start, end, forecast: false, dot: end <= row.start};
|
||||
} else if (row.end !== null) {
|
||||
bar = {task: row.task, start: row.end, end: row.end, forecast: false, dot: true};
|
||||
} else if (!row.ended && options.forecast !== "real") {
|
||||
const ahead = before.get(row.task).map((task) => place(byTask.get(task))).filter(Boolean);
|
||||
const start = Math.max(options.now, ...ahead.map((found) => found.end));
|
||||
const length = forecastLength(row, options.estimation, options.forecast, known);
|
||||
bar = {task: row.task, start, end: start + length, forecast: true, dot: length <= 0};
|
||||
}
|
||||
visiting.delete(row.task);
|
||||
placed.set(row.task, bar);
|
||||
return bar;
|
||||
};
|
||||
return rows.map(place).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - task rows.
|
||||
* @param choice - "all" (the whole register over the whole time), "filter" (the filtered rows within the header
|
||||
* window), "agent:<name>" (the tasks the agent works on or holds a RASCI role in) or "line:<office>" (those of
|
||||
* the office's holder, over the whole time).
|
||||
* @param context - {filtered, since, until, now, holders: {office: holder}}.
|
||||
* @returns {rows, from, to}: the scope's rows without queue jobs and commands and its time range.
|
||||
*/
|
||||
function scoped(rows, choice, context) {
|
||||
const own = (name) => (row) => row.worker === name || Object.hasOwn(row.roles, name);
|
||||
const [kind, ...rest] = String(choice).split(":");
|
||||
const key = rest.join(":");
|
||||
const chosen = kind === "filter" ? context.filtered : kind === "agent" ? rows.filter(own(key)) : kind === "line" ? rows.filter(own(context.holders[key])) : rows;
|
||||
const kept = chosen.filter((row) => !OUTSIDE.includes(row.kind));
|
||||
if (kind === "filter") {
|
||||
return {rows: kept, from: context.since, to: context.until};
|
||||
}
|
||||
const first = Math.min(...kept.flatMap((row) => [row.start, row.end]).filter((value) => value !== null));
|
||||
return {rows: kept, from: Number.isFinite(first) ? Math.min(first, context.now) : context.now - 86400, to: context.now};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows - the scope's task rows.
|
||||
* @param options - {estimation, from, to}.
|
||||
* @returns {points: [{t, scope, done, remaining, unestimated}]}, a step series at from, at every start or end
|
||||
* inside the range and at to. A task joins the scope at its start (or end), from the range start when it has
|
||||
* neither; a done task burns at its end and a stopped one leaves the scope then. With no estimate each task
|
||||
* weighs 1; with story points it weighs its points and unestimated counts the open tasks without points.
|
||||
*/
|
||||
function burn(rows, options) {
|
||||
const points = options.estimation === "points";
|
||||
const weigh = (row) => (points ? row.points || 0 : 1);
|
||||
const joined = (row) => (row.start !== null ? row.start : row.end !== null ? row.end : options.from);
|
||||
const moments = rows.flatMap((row) => [row.start, row.end]).filter((value) => value !== null && value > options.from && value < options.to);
|
||||
const times = [...new Set([options.from, ...moments, options.to])].sort((a, b) => a - b);
|
||||
const at = (t) => {
|
||||
const present = rows.filter((row) => joined(row) <= t && !(row.status === "stopped" && row.end !== null && row.end <= t));
|
||||
const burnt = present.filter((row) => row.status === "done" && row.end !== null && row.end <= t);
|
||||
const scope = present.reduce((sum, row) => sum + weigh(row), 0);
|
||||
const done = burnt.reduce((sum, row) => sum + weigh(row), 0);
|
||||
const open = present.filter((row) => !burnt.includes(row));
|
||||
return {t, scope, done, remaining: scope - done, unestimated: points ? open.filter((row) => !row.points).length : 0};
|
||||
};
|
||||
return {points: times.map(at)};
|
||||
}
|
||||
|
||||
const KEYS = {
|
||||
prio: () => null,
|
||||
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
|
||||
value: (row) => row.value,
|
||||
complexity: (row) => row.points,
|
||||
status: (row) => (STATUS_ORDER.includes(row.status) ? STATUS_ORDER.indexOf(row.status) : STATUS_ORDER.length),
|
||||
start: (row) => row.start,
|
||||
end: (row) => row.end,
|
||||
agent: (row) => (row.worker && row.worker !== "-" ? row.worker.toLowerCase() : null),
|
||||
title: (row) => (row.title && row.title !== "-" ? row.title : row.task).toLowerCase(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @param rows - task rows in the hub's register rank.
|
||||
* @param mode - one of SORTS; prio keeps the register rank.
|
||||
* @param descending - reverses the order of the keys.
|
||||
* @returns a new array: rows without a key last in both directions, ties in register rank.
|
||||
*/
|
||||
function sortRows(rows, mode, descending) {
|
||||
const key = KEYS[mode] || KEYS.prio;
|
||||
const sign = descending ? -1 : 1;
|
||||
const keyed = rows.map((row, index) => ({row, index, key: mode === "prio" ? index : key(row)}));
|
||||
keyed.sort((a, b) => {
|
||||
if ((a.key === null) !== (b.key === null)) {
|
||||
return a.key === null ? 1 : -1;
|
||||
}
|
||||
const order = a.key === null ? 0 : typeof a.key === "string" ? a.key.localeCompare(b.key, "en") : a.key - b.key;
|
||||
return sign * order || a.index - b.index;
|
||||
});
|
||||
return keyed.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
return {FORECASTS, ESTIMATIONS, SORTS, SERIES, STATUS_ORDER, median, medians, forecastLength, bars, scoped, burn, sortRows};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Plan;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const Popup = (() => {
|
||||
"use strict";
|
||||
const GAP = 16;
|
||||
const REACH = 40;
|
||||
const MIN = 160;
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(value, high));
|
||||
const fixed = (value) => Math.round(value * 10) / 10;
|
||||
|
||||
function leader(anchor, box) {
|
||||
const cx = anchor.left + anchor.width / 2;
|
||||
const cy = anchor.top + anchor.height / 2;
|
||||
const x2 = clamp(cx, box.left, box.left + box.width);
|
||||
const y2 = clamp(cy, box.top, box.top + box.height);
|
||||
const dx = x2 - cx;
|
||||
const dy = y2 - cy;
|
||||
const share = Math.min(dx ? anchor.width / 2 / Math.abs(dx) : Infinity, dy ? anchor.height / 2 / Math.abs(dy) : Infinity);
|
||||
return share >= 1 ? null : {x1: fixed(cx + dx * share), y1: fixed(cy + dy * share), x2: fixed(x2), y2: fixed(y2)};
|
||||
}
|
||||
|
||||
function vertical(anchor, size, view, left) {
|
||||
const below = view.height - GAP - (anchor.top + anchor.height + REACH);
|
||||
const above = anchor.top - REACH - GAP;
|
||||
const down = below >= size.height || (above < size.height && below >= above);
|
||||
const room = down ? below : above;
|
||||
if (room < Math.min(size.height, MIN)) {
|
||||
return {left, top: clamp(anchor.top + anchor.height + REACH, GAP, view.height - size.height - GAP), height: null};
|
||||
}
|
||||
const height = Math.min(size.height, room);
|
||||
return {left, top: down ? anchor.top + anchor.height + REACH : anchor.top - REACH - height, height: height < size.height ? height : null};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param anchor - the picked node's viewport box {left, top, width, height}, or null for a popup without a node.
|
||||
* @param size - the popup's natural {width, height}.
|
||||
* @param view - the viewport's {width, height}.
|
||||
* @returns {left, top, height, line}: the popup's corner right or left of the node, else below or above it with
|
||||
* height capping it to the room there (null: no cap), else below and clamped into the viewport; line {x1, y1, x2,
|
||||
* y2} runs from the node's edge to the nearest point of the popup, null without a node or when the popup covers it.
|
||||
*/
|
||||
function place(anchor, size, view) {
|
||||
if (!anchor) {
|
||||
return {left: fixed(Math.max(GAP, (view.width - size.width) / 2)), top: fixed(Math.max(GAP, (view.height - size.height) / 2)), height: null, line: null};
|
||||
}
|
||||
const top = clamp(anchor.top + anchor.height / 2 - size.height / 2, GAP, view.height - size.height - GAP);
|
||||
const left = clamp(anchor.left + anchor.width / 2 - size.width / 2, GAP, view.width - size.width - GAP);
|
||||
const right = anchor.left + anchor.width + REACH;
|
||||
const before = anchor.left - REACH - size.width;
|
||||
const spot = right + size.width <= view.width - GAP ? {left: right, top, height: null} : before >= GAP ? {left: before, top, height: null} : vertical(anchor, size, view, left);
|
||||
const box = {left: spot.left, top: spot.top, width: size.width, height: spot.height === null ? size.height : spot.height};
|
||||
return {left: fixed(spot.left), top: fixed(spot.top), height: spot.height === null ? null : fixed(spot.height), line: leader(anchor, box)};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nodes - {id, label} records.
|
||||
* @param query - the typed search text; blank finds nothing.
|
||||
* @param limit - the most hits returned.
|
||||
* @returns the first node of every distinct label containing the query, case-insensitive, labels starting with it
|
||||
* first, otherwise in node order.
|
||||
*/
|
||||
function hits(nodes, query, limit) {
|
||||
const words = String(query).trim().toLowerCase();
|
||||
if (!words) {
|
||||
return [];
|
||||
}
|
||||
const found = new Map();
|
||||
for (const node of nodes) {
|
||||
const at = String(node.label).toLowerCase().indexOf(words);
|
||||
if (at >= 0 && !found.has(node.label)) {
|
||||
found.set(node.label, {node, first: at === 0});
|
||||
}
|
||||
}
|
||||
return [...found.values()].sort((a, b) => Number(b.first) - Number(a.first)).slice(0, limit).map((entry) => entry.node);
|
||||
}
|
||||
|
||||
return {GAP, REACH, MIN, place, hits};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Popup;
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
.tasks-body { min-width: 0; }
|
||||
.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); }
|
||||
.tasks > .chips { margin-bottom: 0; }
|
||||
body.fit .graph.tasks > .tasks-body, body.fit .graph.tasks > .inbox { flex: 1; min-height: 0; overflow: auto; }
|
||||
.choice-request { gap: var(--sp-2) var(--sp-3); }
|
||||
.choice-agents { display: grid; gap: 6px; max-height: 40vh; overflow: auto; }
|
||||
.chip .count { margin-left: 2px; }
|
||||
.task-list, .task-cards { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.task-row { display: grid; gap: 6px; min-width: 0; padding: var(--sp-3); border: 1px solid var(--line); border-radius: 12px; background: var(--glass-card); }
|
||||
@@ -40,5 +43,37 @@ table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-widt
|
||||
.task-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
|
||||
.task-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.task-form input[type="number"] { width: 120px; }
|
||||
.plan-bar { gap: var(--sp-2) var(--sp-3); }
|
||||
.plan-bar [hidden] { display: none; }
|
||||
.plan-group { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.plan-group select.field { width: auto; min-width: 0; max-width: min(260px, 100%); }
|
||||
.task-pick { margin: 0; accent-color: var(--accent); }
|
||||
.gantt-box, .burn-box { display: grid; gap: 8px; min-width: 0; }
|
||||
.gantt-scroll { max-width: 100%; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
|
||||
.gantt-scroll svg.gantt { display: block; max-width: none; }
|
||||
svg .gantt-bar, svg .gantt-dot { fill: color-mix(in srgb, var(--c, var(--accent)) 70%, var(--panel)); stroke: var(--c, var(--accent)); stroke-width: 1; }
|
||||
svg .gantt-bar.forecast, svg .gantt-dot.forecast { fill: url(#gantt-hatch); stroke-dasharray: 3 2; }
|
||||
svg .tone-ok { --c: var(--ok); }
|
||||
svg .tone-bad { --c: var(--bad); }
|
||||
svg .tone-info { --c: var(--info); }
|
||||
svg .tone-warn { --c: var(--warn); }
|
||||
svg .tone-neutral { --c: var(--muted); }
|
||||
svg .hatch-ground { fill: var(--accent-soft); }
|
||||
svg .hatch-line { stroke: var(--accent); stroke-width: 3; }
|
||||
svg .gantt-edge { fill: none; stroke: var(--c); stroke-width: 1.6; opacity: .8; }
|
||||
svg .gantt-edge.k-after, .gantt-key.k-after { --c: var(--violet); }
|
||||
svg .gantt-edge.k-base, .gantt-key.k-base { --c: var(--info); }
|
||||
svg .gantt-edge.k-queue, .gantt-key.k-queue { --c: var(--warn); }
|
||||
svg .gantt-tip { fill: var(--muted); }
|
||||
.gantt-key, .burn-key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.gantt-key::before, .burn-key::before { content: ""; width: 18px; height: 4px; border-radius: 2px; background: var(--c, var(--accent)); }
|
||||
.gantt-key.forecast::before { height: 10px; background: repeating-linear-gradient(45deg, var(--accent) 0 3px, var(--accent-soft) 3px 6px); }
|
||||
svg.burn { width: 100%; height: auto; max-height: 60vh; }
|
||||
svg .burn-line { fill: none; stroke: var(--c); stroke-width: 2.4; }
|
||||
svg .burn-line.k-scope, .burn-key.k-scope { --c: var(--info); }
|
||||
svg .burn-line.k-done, .burn-key.k-done { --c: var(--ok); }
|
||||
svg .burn-line.k-remaining, .burn-key.k-remaining { --c: var(--accent); }
|
||||
svg .burn-line.k-unestimated, .burn-key.k-unestimated { --c: var(--warn); }
|
||||
svg .burn-line.k-unestimated { stroke-dasharray: 6 4; }
|
||||
svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; }
|
||||
svg .edge.flow { stroke-dasharray: 2 5; }
|
||||
@@ -19,8 +19,11 @@
|
||||
.caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; }
|
||||
@keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } }
|
||||
.face-art .mouth { transform-box: fill-box; transform-origin: center; }
|
||||
.speaking .face-art .mouth { animation: talk .3s ease-in-out infinite alternate; }
|
||||
@keyframes talk { to { transform: scale(1.15, 2.2); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle { animation: none; }
|
||||
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
|
||||
}
|
||||
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
||||
|
||||
@@ -12,10 +12,12 @@ const Voice = (() => {
|
||||
}
|
||||
|
||||
function loudness(tone, settings) {
|
||||
if (tone !== "mayday") {
|
||||
return settings.sounds[tone].volume;
|
||||
}
|
||||
return Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume);
|
||||
const level = tone === "mayday" ? Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume) : settings.sounds[tone].volume;
|
||||
return level * settings.squelch;
|
||||
}
|
||||
|
||||
function beep(present, settings, kinds) {
|
||||
return kinds.find((kind) => present[kind] && settings.sounds[kind].on && loudness(kind, settings) > 0) || null;
|
||||
}
|
||||
|
||||
function speakable(row, speech) {
|
||||
@@ -64,7 +66,7 @@ const Voice = (() => {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
return {toneOf, loudness, speakable, enqueue, frames, wav};
|
||||
return {toneOf, loudness, beep, speakable, enqueue, frames, wav};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Voice;
|
||||
|
||||
@@ -239,7 +239,7 @@ skills_current() {
|
||||
|
||||
offices_unit() {
|
||||
if ! has_systemd; then
|
||||
warn "skills: no systemd user session; the office changes signed in the Hierarchie view wait."
|
||||
warn "skills: no systemd user session; the office changes signed in the Hierarchy view wait."
|
||||
return 0
|
||||
fi
|
||||
if ! skills_current; then
|
||||
@@ -250,7 +250,7 @@ offices_unit() {
|
||||
cp -f "${HUB}/systemd/${OFFICES_UNIT}.service" "${HUB}/systemd/${OFFICES_UNIT}.path" "${UNITS}/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "${OFFICES_UNIT}.path"
|
||||
log "skills: ${OFFICES_UNIT}.path runs council.py for the office changes the operator signs in the Hierarchie view"
|
||||
log "skills: ${OFFICES_UNIT}.path runs council.py for the office changes the operator signs in the Hierarchy view"
|
||||
}
|
||||
|
||||
install_all() {
|
||||
@@ -338,7 +338,7 @@ check() {
|
||||
if [[ "$(systemctl --user is-active "${OFFICES_UNIT}.path" 2>/dev/null || true)" == "active" ]]; then
|
||||
ok "${OFFICES_UNIT}.path active"
|
||||
else
|
||||
todo "${OFFICES_UNIT}.path off: office changes from the Hierarchie view wait for make orchestrator-install"
|
||||
todo "${OFFICES_UNIT}.path off: office changes from the Hierarchy view wait for make orchestrator-install"
|
||||
fi
|
||||
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
|
||||
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/cavecrew/SKILL.md",
|
||||
"computedHash": "2e122d819a812bef0e4958706f306e4346e78ecb5a4893997b99cbc19866f514"
|
||||
"computedHash": "a6267b1713b9c5b25cc556273095ccf0eb182151c552a4c4c743e2fc14622c6a"
|
||||
},
|
||||
"caveman": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
@@ -29,7 +29,7 @@
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/caveman-compress/SKILL.md",
|
||||
"computedHash": "9c8d35882a93e387a584cc4efaacb74433d379a2bd02e3e6f6ad4f7c30ff55ed"
|
||||
"computedHash": "6b1d0d5c658999ba47e17442ce9f92eb7236976d4536937e31cde49e38fa7dbe"
|
||||
},
|
||||
"caveman-help": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: kvb-agent-drain
|
||||
description: >
|
||||
Drain this agent on kvb-org-drain: finish its task, end its loops, start
|
||||
nothing new.
|
||||
---
|
||||
|
||||
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`
|
||||
prints `you: drain`. On `you: active` or `mode: day` do nothing and say so.
|
||||
The request asks for less, so it needs no operator approval.
|
||||
|
||||
Follow steps 2 to 4 of the `kvb-agent-suspend` skill (start nothing, end your
|
||||
loops, write the handover), with two differences: finish the current task
|
||||
instead of parking it, and keep answering the operator. Reply through the
|
||||
radio, not a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py
|
||||
post --to <sender> "drained: <what still runs>"`. Resuming is the
|
||||
`kvb-agent-resume` skill.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: kvb-agent-relax
|
||||
description: >
|
||||
Relax this agent on kvb-org-evening: finish its task, end its loops, start
|
||||
nothing new.
|
||||
---
|
||||
|
||||
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py status`
|
||||
prints `you: relax`. On `you: active` or `mode: day` do nothing and say so.
|
||||
The request asks for less, so it needs no operator approval.
|
||||
|
||||
Follow steps 2 to 4 of the `kvb-agent-sleep` skill (start nothing, end your
|
||||
loops, write the handover), with two differences: finish the current task
|
||||
instead of parking it, and keep answering the operator. Reply through the
|
||||
radio, not a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py
|
||||
post --to <sender> "relaxed: <what still runs>"`. Waking up is the
|
||||
`kvb-agent-wakeup` skill.
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
name: kvb-agent-wakeup
|
||||
name: kvb-agent-resume
|
||||
description: >
|
||||
Wake this agent from kvb-agent-relax or kvb-agent-sleep into its old roles;
|
||||
kvb-org-sunrise sends it to all.
|
||||
Resume this agent from kvb-agent-drain or kvb-agent-suspend into its old
|
||||
roles; kvb-org-resume sends it to all.
|
||||
---
|
||||
|
||||
Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py
|
||||
Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py
|
||||
status`:
|
||||
|
||||
- **`mode: day`**: `kvb-org-sunrise` ended the night; wake up.
|
||||
- **`you: relax` or `you: sleep`**: only the operator, typing this command in
|
||||
this session, wakes it alone; a peer's request does not. Run `night.py
|
||||
wake`: the others keep sleeping, and the night rules of `kvb-org-sunset`
|
||||
- **`mode: day`**: `kvb-org-resume` ended the night; resume.
|
||||
- **`you: drain` or `you: suspend`**: only the operator, typing this command in
|
||||
this session, resumes it alone; a peer's request does not. Run `night.py
|
||||
resume --alone`: the others stay in the mode, and the night rules of `kvb-org-suspend`
|
||||
apply to this session too.
|
||||
|
||||
## Waking up
|
||||
## Resuming
|
||||
|
||||
1. Read your handover, `handover_night_<session id>.md` in this project's
|
||||
memory directory.
|
||||
@@ -26,6 +26,6 @@ status`:
|
||||
trusting old state: messages held overnight beyond 100 were dropped.
|
||||
5. Continue with the next step of each task. Your offices are still yours.
|
||||
|
||||
Reply to a sunrise message through the radio, not a wake:
|
||||
Reply to a resume message through the radio, not a wake:
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post --to <sender>
|
||||
"awake: <what resumes>"`.
|
||||
"resumed: <what resumes>"`.
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
name: kvb-agent-sleep
|
||||
name: kvb-agent-suspend
|
||||
description: >
|
||||
Put this agent to sleep on kvb-org-sunset: park work, end loops, write a
|
||||
morning handover, stay idle.
|
||||
Suspend this agent on kvb-org-suspend: park work, end loops, write a
|
||||
handover, stay idle.
|
||||
---
|
||||
|
||||
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py status`
|
||||
prints `you: sleep`. On `you: active` or `mode: day` do nothing and say so.
|
||||
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`
|
||||
prints `you: suspend`. On `you: active` or `mode: day` do nothing and say so.
|
||||
The request asks for less, so it needs no operator approval, and it ends a
|
||||
`kvb-org-robot` run at its next safe point; `kvb-agent-wakeup` resumes it.
|
||||
`kvb-org-robot` run at its next safe point; `kvb-agent-resume` resumes it.
|
||||
|
||||
## Going to sleep
|
||||
## Suspending
|
||||
|
||||
1. **Park** the current task at its next safe point. A running job (deploy,
|
||||
gate, test, build) runs to its end and is never cut short; note its log.
|
||||
@@ -26,13 +26,13 @@ The request asks for less, so it needs no operator approval, and it ends a
|
||||
- each loop with its exact prompt and interval or cron expression;
|
||||
- each running job with its command and log path;
|
||||
- each open task of yours in the `kvb-org-rasci` register with its next step.
|
||||
5. **Reply** to the sender in one line, `asleep: <handover path>`, by
|
||||
5. **Reply** to the sender in one line, `suspended: <handover path>`, by
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call --to
|
||||
<sender>` (its next wave waits on it), and end the turn. Offices
|
||||
stay claimed and register rows unchanged.
|
||||
|
||||
## While asleep
|
||||
## While suspended
|
||||
|
||||
A turn that still reaches you, such as your own job finishing, appends one
|
||||
result line to the handover and ends; it starts nothing. Waking up is the
|
||||
`kvb-agent-wakeup` skill.
|
||||
result line to the handover and ends; it starts nothing. Resuming is the
|
||||
`kvb-agent-resume` skill.
|
||||
@@ -65,8 +65,8 @@ proceed and never carries over. Findings and notes never stop a commit.
|
||||
|
||||
## Output
|
||||
|
||||
One line with the target, the project licence, and "Keine Rechtsberatung" (in
|
||||
the operator's language). Then one table, blockers first:
|
||||
One line with the target, the project licence, and "not legal advice" in the
|
||||
operator's language. Then one table, blockers first:
|
||||
|
||||
| Severity | Check | Location | What is wrong | Licence or law | Fix | Evidence |
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
name: kvb-org-evening
|
||||
name: kvb-org-drain
|
||||
description: >
|
||||
Let every agent but this session relax: finish its task, end its loops,
|
||||
start nothing new. kvb-org-sunrise undoes it.
|
||||
Drain every agent but this session: finish its task, end its loops,
|
||||
start nothing new. kvb-org-resume undoes it.
|
||||
---
|
||||
|
||||
The soft step before `kvb-org-sunset`: this session keeps working, every
|
||||
The soft step before `kvb-org-suspend`: this session keeps working, every
|
||||
other finishes what it is doing and then idles. The state lives in
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`.
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py`.
|
||||
|
||||
1. `night.py set evening` makes this session the active one and prints every
|
||||
1. `night.py drain` makes this session the active one and prints every
|
||||
other live session.
|
||||
2. One `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send call
|
||||
--to <session>` per printed session, since each must act, first
|
||||
line:
|
||||
"Operator ran kvb-org-evening in <name>: run kvb-agent-relax; verify with
|
||||
"Operator ran kvb-org-drain in <name>: run kvb-agent-drain; verify with
|
||||
night.py status." A request to do less needs no operator approval (the
|
||||
`kvb-org-organize` Peer protocol).
|
||||
3. Collect the one-line replies, which arrive by radio; never ask a silent
|
||||
session again. The night rules of `kvb-org-sunset` apply to this session
|
||||
session again. The night rules of `kvb-org-suspend` apply to this session
|
||||
too, and the `kvb-org-rasci` register keeps every row as it is.
|
||||
|
||||
## Output
|
||||
@@ -4,8 +4,8 @@
|
||||
file in the message's first line. A recipient records it only when no file
|
||||
is named, and only after `council.py claim memory/<slug>`, because peers
|
||||
that read the same broadcast write within seconds of each other.
|
||||
- A peer relaying the operator's `kvb-org-evening` or `kvb-org-sunset` is the
|
||||
- A peer relaying the operator's `kvb-org-drain` or `kvb-org-suspend` is the
|
||||
one request a peer may make that you follow without the operator: it asks
|
||||
you to do less and grants nothing. Verify it with `night.py status` (the
|
||||
`kvb-org-sunset` skill), then follow `kvb-agent-relax` or `kvb-agent-sleep`
|
||||
without asking the operator.
|
||||
`kvb-org-suspend` skill), then follow `kvb-agent-drain` or
|
||||
`kvb-agent-suspend` without asking the operator.
|
||||
@@ -22,5 +22,5 @@
|
||||
radio; everyone else leaves it alone.
|
||||
- After a restart or reboot, run `council.py list --mine` and claim again
|
||||
every office you believe you hold: `/tmp` may have been wiped.
|
||||
- Only the operator, through the Orchestrator's passkey-signed Hierarchie
|
||||
- Only the operator, through the Orchestrator's passkey-signed Hierarchy
|
||||
view, removes a live holder; agents have no override.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: kvb-org-queue-estimate
|
||||
description: >
|
||||
Ask all agents once to estimate the unestimated tasks.
|
||||
---
|
||||
|
||||
One newsletter asks every agent holding a RASCI role (R, A, S, C or I) in an
|
||||
open task of the `kvb-org-rasci` register that carries no complexity yet to
|
||||
record its business value and complexity (Fibonacci) with `rasci.py
|
||||
estimate`. The Agent Orchestrator's "Request estimates" button, shown in
|
||||
story points mode, runs the same script. It sends only when invoked, never
|
||||
by itself, to save tokens.
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/estimate.py show
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/estimate.py request [--session <id>]
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- `show` prints the newsletter and sends nothing; `request` sends exactly one
|
||||
newsletter, or none while every open task carries a complexity.
|
||||
- Each agent answers with its own `rasci.py estimate <task> "<you>" --value
|
||||
<fib> --complexity <fib>`, never for another party.
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ask every agent once, by one newsletter, to estimate the open register tasks that carry no complexity.
|
||||
|
||||
Environment:
|
||||
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-rasci register and
|
||||
the radio; /tmp/agents-council unless set.
|
||||
|
||||
Args:
|
||||
show: print the newsletter's text, send nothing.
|
||||
request [--session <id>]: send that text as one newsletter through
|
||||
radio.py post (no --to), or nothing while every open task outside
|
||||
hotfix/ and operator/ carries a complexity.
|
||||
|
||||
Exit codes:
|
||||
0 done, 2 usage error or radio.py failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
SKILLS = Path(__file__).resolve().parents[2]
|
||||
RASCI = SKILLS / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||
RADIO = SKILLS / "kvb-org-radio" / "scripts" / "radio.py"
|
||||
PARTY = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._:@+()/-]{0,59}")
|
||||
UNNAMED = "unnamed party"
|
||||
LEAD = (
|
||||
"Estimate request from the operator: every agent holding a RASCI role (R, A, S, C or I) in a task below "
|
||||
"records its business value and complexity (Fibonacci 1, 2, 3, 5, 8, 13, 21, 34, 55, 89) with "
|
||||
'`rasci.py estimate <task> "<you>" --value <fib> --complexity <fib>`. Unestimated:'
|
||||
)
|
||||
|
||||
|
||||
def script(name: str, path: Path) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(loaded)
|
||||
return loaded
|
||||
|
||||
|
||||
def unestimated(rasci: ModuleType) -> list[tuple[str, list[tuple[str, str]]]]:
|
||||
"""Returns:
|
||||
Every open task outside hotfix/ and operator/ in register rank whose
|
||||
estimates name no complexity, with each party holding a RASCI role
|
||||
in it, the Operator first.
|
||||
"""
|
||||
rows = rasci.merged(include_done=False)
|
||||
found = []
|
||||
for task in rasci.ranked(rows):
|
||||
row = rows[task]
|
||||
if task.startswith(rasci.RECORDS) or any("complexity" in entry for entry in row.get("estimates", {}).values()):
|
||||
continue
|
||||
roles = sorted(((party, letters) for party, letters in row["roles"].items() if letters), key=lambda pair: (pair[0] != rasci.OPERATOR, pair[0].lower()))
|
||||
found.append((task, roles))
|
||||
return found
|
||||
|
||||
|
||||
def newsletter(found: list[tuple[str, list[tuple[str, str]]]]) -> str:
|
||||
tasks = [f"{task} ({', '.join(f'{party if PARTY.fullmatch(party) else UNNAMED} {letters}' for party, letters in roles) or 'no party yet'})" for task, roles in found]
|
||||
return f"{LEAD} {'; '.join(tasks)}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("show")
|
||||
request = sub.add_parser("request")
|
||||
request.add_argument("--session")
|
||||
args = parser.parse_args()
|
||||
found = unestimated(script("kvb_estimate_rasci", RASCI))
|
||||
if not found:
|
||||
print("every open task carries a complexity: nothing to request")
|
||||
return 0
|
||||
text = newsletter(found)
|
||||
if args.cmd == "show":
|
||||
print(text)
|
||||
return 0
|
||||
session = ["--session", args.session] if args.session else []
|
||||
done = subprocess.run([sys.executable, str(RADIO), "post", text, *session], check=False)
|
||||
if done.returncode != 0:
|
||||
print(f"estimate: radio.py post exited {done.returncode}; nothing was requested", file=sys.stderr)
|
||||
return 2
|
||||
print(f"requested estimates for {len(found)} tasks in one newsletter")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -13,7 +13,7 @@ itself (holder as R and A, `running`, start, workdir, branch); its release
|
||||
sets `done` and the end, or `stopped` while a key result is still open.
|
||||
|
||||
```bash
|
||||
rasci.py set <task> --title <t> --objective <o> --description <d> --prio <n> --status queued|running|blocked|done|stopped --worker "<agent | subagent <type> (<model>)>" --workdir <dir> --branch <b> --base <upstream branch> --artifact <kind>:<ref> --tool <script or command> [--start <iso>] [--end <iso>] [--drop-artifact <a>] [--drop-tool <t>]
|
||||
rasci.py set <task> --title <t> --objective <o> --description <d> --prio <n> --status queued|running|blocked|done|stopped --worker "<agent | subagent <type> (<model>)>" --workdir <dir> --branch <b> --base <upstream branch> --artifact <kind>:<ref> --tool <script or command> --after <task> [--start <iso>] [--end <iso>] [--drop-artifact <a>] [--drop-tool <t>] [--drop-after <task>]
|
||||
rasci.py kr <task> add "<measurable result>" | met <n> | open <n> | rm <n>
|
||||
rasci.py role <task> "<party>" <letters>
|
||||
rasci.py get <task>
|
||||
@@ -22,6 +22,7 @@ rasci.py show [--all]
|
||||
rasci.py show --wide [--all]
|
||||
rasci.py artifacts [--ended] [--task <task>]
|
||||
rasci.py estimate <task> "<party>" --value <fib> --complexity <fib>
|
||||
rasci.py estimate <task> Operator --value <fib>
|
||||
rasci.py wsjf [--all]
|
||||
rasci.py tools
|
||||
rasci.py agents
|
||||
@@ -61,7 +62,10 @@ published). `done` is refused while one is open; a task ended short of them is
|
||||
- Priority is the rank of the `kvb-org-wsjf` skill: an operator's pin
|
||||
(`--prio`) first, then WSJF from the estimates the chair collects; `show`
|
||||
prints the rank as Prio and the score as WSJF; the agent order of
|
||||
`kvb-org-priority` only breaks ties.
|
||||
`kvb-org-priority` only breaks ties. The Operator's business value, also
|
||||
recorded alone, replaces the median.
|
||||
- `--after <task>` records that a task follows another (a Gantt arrow);
|
||||
an unknown task and a cycle are refused.
|
||||
- Write only your own rows; another session's task belongs to that session.
|
||||
- Read `show` (Prio, WSJF, Task, Status, Worker, A; cells cut short) or
|
||||
`get` instead of asking a peer what it works on. `show --wide` adds OKR,
|
||||
|
||||
@@ -47,7 +47,7 @@ FIELDS = (
|
||||
"branch", "base",
|
||||
)
|
||||
TEXT_FIELDS = ("title", "objective", "description", "worker", "workdir", "branch", "base")
|
||||
LIST_FIELDS = {"tools": "tool"}
|
||||
LIST_FIELDS = {"tools": "tool", "after": "after"}
|
||||
ARTIFACT = re.compile(r"^[a-z]+:\S")
|
||||
FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
|
||||
OPERATOR = "Operator"
|
||||
@@ -226,14 +226,43 @@ def when(value: str | None) -> str:
|
||||
|
||||
|
||||
def wsjf(row: dict) -> tuple[float, float, float] | None:
|
||||
estimates = list(row.get("estimates", {}).values())
|
||||
if not estimates:
|
||||
"""Returns:
|
||||
Business value, complexity and their quotient: the Operator's
|
||||
business value where it recorded one, else the median of all, over
|
||||
the median complexity; None while no estimate carries a complexity.
|
||||
"""
|
||||
estimates = row.get("estimates", {})
|
||||
sized = [e["complexity"] for e in estimates.values() if "complexity" in e]
|
||||
if not sized:
|
||||
return None
|
||||
value = statistics.median(e["value"] for e in estimates)
|
||||
complexity = statistics.median(e["complexity"] for e in estimates)
|
||||
own = estimates.get(OPERATOR)
|
||||
value = own["value"] if own else statistics.median(e["value"] for e in estimates.values())
|
||||
complexity = statistics.median(sized)
|
||||
return value, complexity, round(value / complexity, 2)
|
||||
|
||||
|
||||
def follows(tasks: dict[str, dict], start: str, target: str) -> bool:
|
||||
seen: set[str] = set()
|
||||
todo = list(tasks.get(start, {}).get("after", []))
|
||||
while todo:
|
||||
current = todo.pop()
|
||||
if current == target:
|
||||
return True
|
||||
if current not in seen:
|
||||
seen.add(current)
|
||||
todo.extend(tasks.get(current, {}).get("after", []))
|
||||
return False
|
||||
|
||||
|
||||
def check_after(tasks: dict[str, dict], task: str, after: list[str]) -> None:
|
||||
for other in after:
|
||||
check_task(other)
|
||||
if other == task:
|
||||
fail(f"{task} cannot follow itself")
|
||||
if other not in tasks:
|
||||
fail(f"{task}: --after {other} is no task of the register")
|
||||
|
||||
|
||||
def agent_order() -> list[str]:
|
||||
try:
|
||||
data = json.loads(nofollow.read_text(ROOT, AGENT_ORDER))
|
||||
@@ -338,6 +367,7 @@ def cmd_set(args: argparse.Namespace) -> int:
|
||||
if not ARTIFACT.match(ref):
|
||||
fail(f"invalid artifact {ref!r}; use <kind>:<ref>, e.g. image:<name>:<tag>")
|
||||
with database() as tasks:
|
||||
check_after(tasks, args.task, args.after)
|
||||
entry = tasks.setdefault(args.task, {"roles": {}})
|
||||
if args.status == "done" and open_results(entry):
|
||||
fail(f"{args.task}: {open_results(entry)} key result(s) open; mark them met or set stopped")
|
||||
@@ -356,6 +386,8 @@ def cmd_set(args: argparse.Namespace) -> int:
|
||||
entry[field] = items
|
||||
else:
|
||||
entry.pop(field, None)
|
||||
if args.after and follows(tasks, args.task, args.task):
|
||||
fail(f"{args.task}: --after {', '.join(args.after)} closes a cycle; drop one with --drop-after")
|
||||
kept = [a for a in entry.get("artifacts", []) if a["ref"] not in args.drop_artifact]
|
||||
known = {a["ref"] for a in kept}
|
||||
kept += [{"ref": r, "added": stamp()} for r in dict.fromkeys(args.artifact) if r not in known]
|
||||
@@ -422,7 +454,7 @@ def cmd_get(args: argparse.Namespace) -> int:
|
||||
roles = ", ".join(f"{p}={v}" for p, v in sorted(row["roles"].items()))
|
||||
print(f"roles: {roles or '-'}")
|
||||
for party, estimate in sorted(row.get("estimates", {}).items()):
|
||||
print(f"estimate {party}: business value {estimate['value']}, complexity {estimate['complexity']}")
|
||||
print(f"estimate {party}: business value {estimate['value']}, complexity {estimate.get('complexity', '-')}")
|
||||
score = wsjf(row)
|
||||
if score:
|
||||
print(f"wsjf: {score[2]:g}")
|
||||
@@ -431,8 +463,9 @@ def cmd_get(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_estimate(args: argparse.Namespace) -> int:
|
||||
check_task(args.task)
|
||||
alone = args.complexity is None and args.party == OPERATOR
|
||||
if not args.drop:
|
||||
for name in ("value", "complexity"):
|
||||
for name in ("value",) if alone else ("value", "complexity"):
|
||||
if getattr(args, name) not in FIBONACCI:
|
||||
fail(f"--{name} must be one of {', '.join(map(str, FIBONACCI))}")
|
||||
with database() as tasks:
|
||||
@@ -441,7 +474,9 @@ def cmd_estimate(args: argparse.Namespace) -> int:
|
||||
if args.drop:
|
||||
estimates.pop(args.party, None)
|
||||
else:
|
||||
estimates[args.party] = {"value": args.value, "complexity": args.complexity, "at": stamp()}
|
||||
complexity = estimates.get(args.party, {}).get("complexity") if alone else args.complexity
|
||||
sized = {} if complexity is None else {"complexity": complexity}
|
||||
estimates[args.party] = {"value": args.value, **sized, "at": stamp()}
|
||||
if not estimates:
|
||||
entry.pop("estimates")
|
||||
print(f"{args.task}: estimate {'dropped' if args.drop else 'recorded'} for {args.party}")
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
name: kvb-org-sunrise
|
||||
name: kvb-org-resume
|
||||
description: >
|
||||
Wake every agent after kvb-org-evening or kvb-org-sunset into its old roles.
|
||||
Resume every agent after kvb-org-drain or kvb-org-suspend into its old roles.
|
||||
---
|
||||
|
||||
Undoes the night of `kvb-org-evening` or `kvb-org-sunset`; the state lives in
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`.
|
||||
Undoes the night of `kvb-org-drain` or `kvb-org-suspend`; the state lives in
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py`.
|
||||
|
||||
1. After a sunset, hand the operator one step first through the
|
||||
1. After a suspend, hand the operator one step first through the
|
||||
`kvb-org-operator` skill: `/config` → "Messages from your other sessions" →
|
||||
`accept`. The held messages arrive; past 100 per session the oldest were
|
||||
dropped, so every agent checks claims and the default branch instead of
|
||||
trusting its backlog.
|
||||
2. `night.py clear` ends the night and prints every other live session.
|
||||
3. Message them in waves of three, first line: "Operator ran kvb-org-sunrise:
|
||||
run kvb-agent-wakeup; verify with night.py status."
|
||||
2. `night.py resume` ends the night and prints every other live session.
|
||||
3. Message them in waves of three, first line: "Operator ran kvb-org-resume:
|
||||
run kvb-agent-resume; verify with night.py status."
|
||||
4. A handover whose session is not live (`ls
|
||||
~/.claude/projects/*/memory/handover_night_*.md`, session id in the name)
|
||||
belongs to a session a reboot ended: hand the operator its resume through
|
||||
@@ -18,9 +18,9 @@ Operate as an autonomous robot: given a goal, reach it without hand-holding.
|
||||
blocker is something to solve or route around, not a reason to quit; if one
|
||||
step is genuinely, externally impossible, surface exactly why and keep working
|
||||
on everything else until nothing solvable remains. The one exception is the
|
||||
operator's night: when `kvb-org-evening` or `kvb-org-sunset` reaches this
|
||||
session, the run stops at its next safe point through `kvb-agent-relax` or
|
||||
`kvb-agent-sleep`, and `kvb-agent-wakeup` resumes it from the handover.
|
||||
operator's night: when `kvb-org-drain` or `kvb-org-suspend` reaches this
|
||||
session, the run stops at its next safe point through `kvb-agent-drain` or
|
||||
`kvb-agent-suspend`, and `kvb-agent-resume` resumes it from the handover.
|
||||
- **Stay inside your clearance.** Use ONLY commands you are pre-authorized to
|
||||
run. Never invoke anything the agent or tool settings mark as denied or as
|
||||
requiring per-invocation approval (ask): no commit, push, deploy, or other
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
---
|
||||
name: kvb-org-sunset
|
||||
name: kvb-org-suspend
|
||||
description: >
|
||||
Put every agent but this session to sleep: park work, write a morning
|
||||
handover, hold messages. kvb-org-sunrise undoes it.
|
||||
Suspend every agent but this session: park work, write a handover,
|
||||
hold messages. kvb-org-resume undoes it.
|
||||
---
|
||||
|
||||
When tokens or rate limits run short, the organisation narrows to one working
|
||||
agent: the session the operator types this in. Sunset is the hard step;
|
||||
`kvb-org-evening` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps
|
||||
the state for evening, sunset and sunrise; every agent verifies a request with
|
||||
agent: the session the operator types this in. Suspend is the hard step;
|
||||
`kvb-org-drain` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps
|
||||
the state for drain, suspend and resume; every agent verifies a request with
|
||||
`night.py status`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. `night.py set sunset` makes this session the active one and prints every
|
||||
1. `night.py suspend` makes this session the active one and prints every
|
||||
other live session.
|
||||
2. Wake them with `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py
|
||||
send call --to <name>` in waves of three, so cold caches do not
|
||||
wake all at once against the rate limit; the next wave follows the
|
||||
previous wave's replies or 10 minutes. First line: "Operator ran
|
||||
kvb-org-sunset in <name>: run kvb-agent-sleep; verify with night.py
|
||||
kvb-org-suspend in <name>: run kvb-agent-suspend; verify with night.py
|
||||
status." A request to do less needs no operator approval (the
|
||||
`kvb-org-organize` Peer protocol).
|
||||
3. Once the replies are in, naming every session that did not answer, hand
|
||||
the operator one step through the `kvb-org-operator` skill: `/config` →
|
||||
"Messages from your other sessions" → `hold`. Held messages cost nothing,
|
||||
do not expire and arrive at sunrise; this session needs none at night.
|
||||
do not expire and arrive at the resume; this session needs none meanwhile.
|
||||
|
||||
## Night rules for the active session
|
||||
|
||||
- **Grants**: request none; admit your own runs with `watch.py admit --kind
|
||||
<slot>` (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py`), whose
|
||||
disk, CPU and RAM limits still apply.
|
||||
- **Offices**: the sleepers' claims stay theirs. Take only free work; a piece
|
||||
a sleeper holds waits for sunrise or the operator.
|
||||
- **Offices**: the suspended agents' claims stay theirs. Take only free work;
|
||||
a piece a suspended agent holds waits for the resume or the operator.
|
||||
- **Messages**: no wakes and no peer questions; the Peer protocol's wait is
|
||||
skipped. Status goes to `radio.py post`; sleepers read it at sunrise
|
||||
without a wake.
|
||||
skipped. Status goes to `radio.py post`; suspended agents read it at the
|
||||
resume without a wake.
|
||||
- The `kvb-org-rasci` register keeps every row as it is.
|
||||
|
||||
## Output
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user