From 7d9333b67b9c04382ca15d9da091dfde643aeac4 Mon Sep 17 00:00:00 2001 From: nindya zahra kusuma Date: Wed, 14 Oct 2026 00:21:08 +0700 Subject: [PATCH 1/2] [verified] LokiDewa Council foundation: alerts board, research feed, council meetings, Hermes provider, lobby - Evidence-first alert board (protocol, server, ws view, 2D/3D UI) with a research-feed loader that fails closed and never promotes UNKNOWN verdicts - Council meeting pattern: mandate-scoped councillors, challenge rounds, evidence-first decision output with replay - Hermes Agent provider adapter (model/effort passthrough, session resume intent) - Lobby hub with runtime-truth integration statuses (configured != live) - 11 new test files; full suite 635/635, typecheck, build green Known caveats (follow-ups): Hermes session-id capture/hook wiring needs a dedicated pass; research report path should become env-only upstream; lobby branding is deployment-specific. --- README.md | 6 +- docs/features.md | 4 +- docs/how-it-works.md | 2 +- src/client/features/hud/index.ts | 16 +++-- src/client/lite.css | 5 ++ src/client/lite.html | 14 ++-- src/client/lite.ts | 15 +++-- src/client/state/core.ts | 11 +++- src/client/state/store.ts | 4 +- src/client/ui/alerts.css | 10 +++ src/client/ui/alerts.ts | 60 +++++++++++++++++ src/client/ui/lobby.css | 2 + src/client/ui/lobby.ts | 88 +++++++++++++++++++++++++ src/client/ui/meeting.ts | 51 ++++++++++++-- src/client/world/office/meeting-room.ts | 28 +++++++- src/server/alerts.ts | 42 ++++++++++++ src/server/council-parts.ts | 71 ++++++++++++++++++++ src/server/floor.ts | 16 ++++- src/server/meetings.ts | 4 ++ src/server/providers/hermes.ts | 45 +++++++++++++ src/server/providers/index.ts | 2 + src/server/research-feed.ts | 54 +++++++++++++++ src/server/ws/handlers/alerts.ts | 7 ++ src/server/ws/handlers/index.ts | 2 + src/shared/access.ts | 17 +++++ src/shared/meetings.ts | 57 +++++++++++++++- src/shared/presence.ts | 8 +++ src/shared/prompts.ts | 32 +++++++++ src/shared/protocol.ts | 3 + src/shared/protocol/alerts.ts | 42 ++++++++++++ src/shared/protocol/floors.ts | 3 + src/shared/protocol/meetings.ts | 20 +++++- src/shared/providers.ts | 33 +++++++++- tests/access.test.ts | 9 +++ tests/agents.test.ts | 4 +- tests/alerts-ui.test.ts | 22 +++++++ tests/alerts.test.ts | 36 ++++++++++ tests/client-store.test.ts | 4 +- tests/council-decision.test.ts | 19 ++++++ tests/council-parts.test.ts | 27 ++++++++ tests/lobby.test.ts | 62 +++++++++++++++++ tests/meeting-replay.test.ts | 20 ++++++ tests/presence.test.ts | 13 ++++ tests/research-alerts.test.ts | 34 ++++++++++ tests/research-feed.test.ts | 45 +++++++++++++ tests/research-wiring.test.ts | 30 +++++++++ tests/server-dispatch.test.ts | 2 +- tests/size.test.ts | 2 +- 48 files changed, 1063 insertions(+), 40 deletions(-) create mode 100644 src/client/ui/alerts.css create mode 100644 src/client/ui/alerts.ts create mode 100644 src/client/ui/lobby.css create mode 100644 src/client/ui/lobby.ts create mode 100644 src/server/alerts.ts create mode 100644 src/server/council-parts.ts create mode 100644 src/server/providers/hermes.ts create mode 100644 src/server/research-feed.ts create mode 100644 src/server/ws/handlers/alerts.ts create mode 100644 src/shared/access.ts create mode 100644 src/shared/presence.ts create mode 100644 src/shared/protocol/alerts.ts create mode 100644 tests/access.test.ts create mode 100644 tests/alerts-ui.test.ts create mode 100644 tests/alerts.test.ts create mode 100644 tests/council-decision.test.ts create mode 100644 tests/council-parts.test.ts create mode 100644 tests/lobby.test.ts create mode 100644 tests/meeting-replay.test.ts create mode 100644 tests/presence.test.ts create mode 100644 tests/research-alerts.test.ts create mode 100644 tests/research-feed.test.ts create mode 100644 tests/research-wiring.test.ts diff --git a/README.md b/README.md index fac9441a5..591b2e7d3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **A 3D office your team shares with its coding agents.** -Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, +Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness**, **Hermes Agent** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, and jump into any of them together. Every GitHub repo is a floor of the building. [![Release](https://img.shields.io/github/v/release/AgentSystemLabs/agent-office?style=flat-square&color=e8c547&label=release)](https://github.com/AgentSystemLabs/agent-office/releases) @@ -33,7 +33,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i ## What it is - **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout. -- **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. +- **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi, Hermes Agent or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type. - **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎀**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install. - **You can't miss who needs you.** A worker that stops to ask you something lights a red beacon over its desk, puts a banner on your screen saying who and what for, and sounds an alarm. One that has finished jumps up and down and dings. Press **N** to go straight to whoever is waiting. - **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer. @@ -378,7 +378,7 @@ deploy/coolify.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit | M | Mute / unmute in voice | | Ctrl + Space | Dictate into a terminal or a prompt box: hold it and talk (or hold the **🎀**) | | Tab | The ☰ menu: every window | -| Esc | Close any window. In a terminal, **Hide**, **βœ•** and Esc only hide the terminal; the worker keeps running | +| Esc | Close any window | | Ctrl + [ | Send Esc to a terminal, to close a menu like Claude's `/skills` or interrupt Claude (or **βŽ‹ Esc** in its header) | The full list is in [docs/controls.md](docs/controls.md). diff --git a/docs/features.md b/docs/features.md index f320d9b99..33918c9b4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,7 +20,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Take a seat.** Press **E** at the lounge couch, a beanbag, the balcony bench or a stool at its bistro table, or the couch or the boss's chair up in the boss office to sit down. Walk off, jump or press **E** again to get up. Everyone on your floor sees you sitting. Sit on the couch facing the TV while someone's sharing their screen and it opens full screen for you. - **Minesweeper on the boss's monitor.** In the boss's chair, **E** plays Minesweeper on the monitor: the camera moves in close and the board sits right on the screen. Click to dig, right-click (or Shift-click) to flag, and click a number to dig around it once its mines are flagged. The first click is never a mine. **Stop playing** leaves your game up on the monitor, with its clock paused, until you come back. Walk off to get up. - **Pick your character.** The first time you join, a character select screen lets you choose your name, skin tone, hair style, hair color and shirt, with a spinning preview. No name in mind? Leave it blank (or skip the screen) and you go by the made-up one shown in the box, like *Sunny Otter*, or press **🎲** for another. The office remembers your name and look in that browser, so you only pick once. Change it any time from **βš™οΈ** or by clicking your name under *In the office* (turn on **πŸ‘₯ People** in the **☰** menu). -- **Hire workers.** Walk up to an empty desk and press **E** to choose Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor, or press **P** to write a task first. A little worker sits down, a laptop opens, and the agent's live screen appears on it. Choose the agent for each queue task too. +- **Hire workers.** Walk up to an empty desk and press **E** to choose Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi, Hermes Agent or Cursor, or press **P** to write a task first. A little worker sits down, a laptop opens, and the agent's live screen appears on it. Choose the agent for each queue task too. - **Signs over the desks.** Press **L** at any desk, empty or not, to hang a big sign from the ceiling over it: *Operations*, *Code cleanup*, *Frontend*, whatever that desk is for. Type up to 32 characters (or pick one of the ideas), choose one of seven colors and press **Enter**. It hangs over the far edge of the desk, facing the chair, so it reads over the shoulder of whoever sits there; while the desk back to back with it has no sign of its own, it says the same on the back, so you can read it from across the room either way. Everyone on the floor sees it, the hint at the desk names it, and it stays up across restarts. **L** again changes it or takes it down. Each floor has its own. - **Room to grow.** When a floor needs more desks, the north wall past the gong comes down. Walk up to the yellow **🚧 Room to grow** sign on it and press **E**, then **πŸ”¨ Knock through**: the wall goes, and a back office opens behind it with a pair of desks, a rug, a lamp and a window, in a puff of confetti. Knock through once more and it goes back another row, for two more desks (two rows is as far as it goes before the street behind the building). New workers, and the task queue, take its desks once the room's are full, before any bean bag comes out. The sign moves to the back wall, where **E** can also wall the last row back up, once nobody's working there. Each floor is built out on its own, and from outside you see it: the back office sticks out of that storey, on posts down to the lawn. - **Bean bags for the overflow.** There are 16 desks, and up to 4 more in the back office. Once they're all taken, a bean bag comes out on the floor with a low lap desk in front of it. Hire a worker there the same way as at a desk, and the next bean bag comes out, up to 12 around the room. The task queue seats workers on them too. A bean bag stays out while a worker sits on it, and the empty one goes away again as soon as a desk frees up. @@ -40,7 +40,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Notifications.** You don't have to be in the office tab. The tab title counts the workers waiting on someone, like `(2) my-project Β· Agent Office`. Allow desktop notifications (the office asks when you first hire a worker, or turn them on under **βš™οΈ**) and a worker that needs you or finishes while you're in another tab or app pops one up (*πŸ™‹ Byte needs you*, with what it's asking; that one stays up until it's dealt with). Click it to go straight to that worker: the office comes to the front with you at its desk and its terminal open. For the whole team, paste a Slack or Discord incoming webhook under **βš™οΈ** β†’ *Team notifications* (or start with `--webhook `), and the office posts to that channel when a worker has needed input or been done for a few seconds with nobody at its terminal. - **The 2D view, for your phone.** Open `/lite` (or **πŸ“± 2D view** in the **☰** menu) for the office without the 3D. It lists every worker on the floor, the ones waiting on someone first, each with what it's asking (*πŸ™‹ Wants permission: Bash: npm test*) or doing, its branch and PR, and who typed to it last. Tap one for its terminal, with a row of the keys a phone keyboard hasn't got (**1** **2** **3** to answer a question or a permission prompt, the arrows, Enter, Tab, Esc and Ctrl+C) and a box to send it a prompt. **✍️** on a card sends one without opening the terminal. **πŸ“Œ Issues**, **πŸ”€ PRs**, **πŸ“‹ Queue** and **✨ New task** open the same windows as in the office, and the floor at the top switches projects, with a button straight to any floor where someone's waiting. The tab title counts who's waiting, and once you allow notifications (**πŸ””**) a worker that needs you pops one up and buzzes the phone. The 3D office offers the 2D view as it loads on a phone, and on a computer where it draws fewer than 20 frames a second for ten seconds. **Stay in 3D** stops it asking in that browser. A browser that can't draw the 3D office at all (no WebGL) goes straight to the 2D view, and signing in from `/lite` brings you back there. Teammates see you under *In the office* as *πŸ“± on the 2D view*, not standing anywhere in it. - **Task cards.** A card over each worker's head names what it's on ("Fix Login Redirect") and says in one line what it's doing right now. For Claude workers, Claude Haiku writes it from prompts and recent tools through the `claude` CLI. OpenCode, Codex, Grok, Muse, DeepSeek Harness and Cursor use local task summaries and never invoke Claude to name their tasks. -- **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said. +- **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi, Hermes Agent or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said. - **Search.** Press **/** (or **πŸ”Ž Search** in the **☰** menu) to search the chat and every worker's terminal at once, including what they showed before the office restarted. Click a terminal line to open that terminal scrolled to it, with the line lit up. - **Command palette.** Press **Ctrl+K** (**⌘K** on a Mac) anywhere in the office to find anything on your floor by typing a few letters: workers (by name, task card or desk), issues, pull requests, services, the boards, teammates, and actions (hire a worker, open the task queue, settings, invite, hang a picture). Words, word starts and scattered letters all match, best first, so "login" finds the worker whose card says *Fix Login Redirect*. **↑ ↓** choose and **Enter** opens it the way clicking it in the office does: a worker's terminal, a PR's window, a service in a new tab. **Shift+Enter** walks your character over to it first and opens it when you get there (take a step yourself to call it off). **Esc** or **Ctrl+K** again puts it away. It doesn't open while you're typing in a terminal or a text box, where Ctrl+K is the terminal's own (cut to the end of the line). - **Cost per worker.** Claude Code workers show tracked session cost and tokens. OpenCode workers show tokens and model/provider-reported cost estimates, including child-session usage, with a cache/reasoning breakdown in tooltips. These estimates are not billing; missing cost is shown as unavailable. Codex workers show the root session’s recorded tokens, including cache and reasoning breakdowns; cost and API-call count are unavailable. DeepSeek Harness workers show what is in each session's context window (and the window's size) from ACP's usage updates, plus a cost only when the harness reports one. Today's and all-time totals, `--budget` warnings and `--budget-pause` use tracked Claude Code spend only; they cannot cap OpenCode, Codex or DeepSeek Harness spending. Once the tracked budget is spent, the hiring pause applies to all new workers. OpenCode and Codex session snapshots and Claude totals survive restarts. Current-desk totals disappear when their workers are sent home. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 471783328..c28693bfc 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -44,7 +44,7 @@ browser ──HTTPS/WSS──▢ agent-office (Node) ## Security notes -Anyone who can sign in can drive Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor in that directory, and through it run commands as the user that runs the office. Treat the password, the accounts and the invite links like SSH access: +Anyone who can sign in can drive Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi, Hermes Agent or Cursor in that directory, and through it run commands as the user that runs the office. Treat the password, the accounts and the invite links like SSH access: - The office listens on `127.0.0.1` unless you pass `--host`, and `deploy/provision.sh` keeps it there, behind SSH, Caddy or Tailscale Serve. The sign-in link the terminal prints works once, and the office keeps only a hash of it, in memory. - Use a strong password and HTTPS. With `--trust-proxy`, cookies are `Secure` once the proxy says the request came over https. diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..1b1a4835c 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -11,11 +11,13 @@ import type { Parts } from '../../core/parts'; import { waitingInOrder, waitingLabel } from '../../nextup'; import { saveSettings, store } from '../../state'; import { openAccounts } from '../../ui/accounts'; +import { openAlerts } from '../../ui/alerts'; import { openBoard } from '../../ui/boards'; import { openCharacter } from '../../ui/character'; import { $ } from '../../ui/dom'; import { toggleFloorMenu } from '../../ui/floormenu'; import { openHelp } from '../../ui/hud'; +import { openLobby } from '../../ui/lobby'; import { mountHud } from '../../ui/menu'; import { openServices } from '../../ui/services'; import { openSettings, type SettingsPane } from '../../ui/settings'; @@ -49,20 +51,22 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { const noMedia = () => (window.isSecureContext ? undefined : 'Voice and screen sharing need HTTPS or localhost β€” use a TLS proxy, --self-signed, or an SSH tunnel'); const hud = mountHud( [ + { id: 'lobby', icon: '🏠', label: 'Dewa Alpha lobby', section: 'Open', title: () => 'Today’s brief and the fastest way into every workspace', run: () => openLobby({ council: parts.meeting.showMeeting, canvas: () => openWhiteboard(net), alerts: openAlerts }) }, { id: 'issues', icon: 'πŸ“Œ', label: 'Issues', section: 'Open', count: () => store.issues.items.filter((i) => i.state === 'OPEN').length, run: () => openBoard('issues', net, actions.boardActions()) }, { id: 'pulls', icon: 'πŸ”€', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: 'πŸ“‹', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, + { id: 'alerts', icon: 'πŸ“‘', label: 'Dewa Alpha alerts', section: 'Open', count: () => store.alerts.cards.length, title: () => 'WHY NOW cards with evidence, freshness, coverage, and invalidation', run: openAlerts }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, - { id: 'whiteboard', icon: 'πŸ“', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, - // Up on the top bar while a meeting is on: what's being worked through in the meeting room. + { id: 'whiteboard', icon: '🧠', label: 'Research canvas', section: 'Open', title: () => 'Map narratives, wallet evidence, hypotheses, and next actions together', run: () => openWhiteboard(net) }, + // Up on the top bar while a council session is on: the decision being worked through in the war room. { id: 'meeting', - icon: '🀝', - label: 'Meeting room', + icon: 'πŸ›οΈ', + label: 'Council war room', section: 'Open', status: () => store.meeting.current?.status === 'running', - chip: () => 'In a meeting', - title: () => 'Call a meeting: workers work through a question or a task together', + chip: () => 'Council active', + title: () => 'Call LokiDewa Council to research, challenge evidence, decide, and assign next actions', run: () => parts.meeting.showMeeting(), }, { id: 'search', icon: 'πŸ”Ž', label: 'Search', section: 'Open', key: '/', title: () => 'Search the chat and every terminal', run: waiting.showSearch }, diff --git a/src/client/lite.css b/src/client/lite.css index 0cf69877b..b95aa9775 100644 --- a/src/client/lite.css +++ b/src/client/lite.css @@ -22,6 +22,10 @@ body.lite { min-height: 100dvh; background: var(--paper); -webkit-tap-highlight- .lite-go { justify-content: space-between; min-height: 44px; background: #ffd6e0; white-space: normal; text-align: left; } .lite-go:hover { background: #ffc2d1; } .lite-h { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; margin: 4px 0 10px; font-size: 14px; font-weight: 900; text-transform: uppercase; letter-spacing: .05em; } +.lite-brief { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin:10px 0 16px; } +.lite-brief div { display:flex; flex-direction:column; padding:10px; border:2px solid var(--ink); border-radius:10px; background:#fff; box-shadow:0 2px 0 var(--ink); } +.lite-brief b { font-size:18px; overflow:hidden; text-overflow:ellipsis; } +.lite-brief small { color:var(--muted); font-weight:800; } .lite-waiting { font-size: 13px; text-transform: none; letter-spacing: 0; color: #c9184a; } /* A card per worker, the ones waiting on someone first (see byUrgency). */ @@ -65,6 +69,7 @@ body.lite { min-height: 100dvh; background: var(--paper); -webkit-tap-highlight- /* On a phone, the big windows take the whole screen; the small ones stay dialogs. */ @media (max-width: 640px) { + .lite-brief { grid-template-columns:repeat(2,minmax(0,1fr)); } .backdrop { padding: 8px; } .backdrop:has(> .modal.term, > .modal.board, > .modal.gh-window, > .modal.desk-changes, > .modal.meeting-window, > .modal[aria-label="Task queue"]) { padding: 0; } /* body.lite outranks the windows' own sizes and padding, whatever order the sheets load in. */ diff --git a/src/client/lite.html b/src/client/lite.html index 50f7f23fe..892bba295 100644 --- a/src/client/lite.html +++ b/src/client/lite.html @@ -20,14 +20,20 @@

+
+
0agents active
+
0alpha signals
+
0open tasks
+
Readycouncil
+

Workers

    diff --git a/src/client/lite.ts b/src/client/lite.ts index 49aaf6f0e..3ac714897 100644 --- a/src/client/lite.ts +++ b/src/client/lite.ts @@ -22,6 +22,7 @@ import { openPull, routePullMessage } from './ui/pull'; import { openQueue } from './ui/queue'; import { openAsk } from './ui/ask'; import { openMeeting, type MeetingPreset } from './ui/meeting'; +import { openAlerts } from './ui/alerts'; import { openSignIns } from './ui/signins'; import { modelBadge, providerLabel } from './ui/provider'; import { byUrgency, waitingInOrder, waitingLabel } from './nextup'; @@ -317,20 +318,26 @@ function showMeeting(preset?: MeetingPreset) { ); } -$('btn-issues').addEventListener('click', () => openBoard('issues', net, boardActions())); -$('btn-pulls').addEventListener('click', () => openBoard('pulls', net, boardActions())); +$('btn-alerts').addEventListener('click', openAlerts); +$('btn-council').addEventListener('click', () => showMeeting({ pattern: 'council' })); $('btn-queue').addEventListener('click', () => openQueue(net, { openTerminal: openWorker })); $('btn-new').addEventListener('click', () => sendToWorker('✨ New task')); function renderNav() { const count = (id: string, n: number) => ($(id).querySelector('.n')!.textContent = n ? String(n) : ''); - count('btn-issues', store.issues.items.filter((i) => i.state === 'OPEN').length); - count('btn-pulls', store.pulls.items.filter((p) => p.state === 'OPEN').length); + count('btn-alerts', store.alerts.cards.length); count('btn-queue', store.queue.tasks.filter((t) => t.status !== 'done').length); + $('brief-agents').textContent = String([...store.workers.values()].filter((w) => w.status !== 'done' && w.status !== 'exited').length); + $('brief-alerts').textContent = String(store.alerts.cards.length); + $('brief-tasks').textContent = String(store.queue.tasks.filter((t) => t.status !== 'done').length); + $('brief-council').textContent = store.meeting.current?.status === 'running' ? 'Live' : 'Ready'; } store.on('issues', renderNav); store.on('pulls', renderNav); store.on('queue', renderNav); +store.on('alerts', renderNav); +store.on('workers', renderNav); +store.on('meeting', renderNav); // ---- What you have open, for the others (see PeerInfo.doing) ----------------------------------- let doingSent: string | undefined; diff --git a/src/client/state/core.ts b/src/client/state/core.ts index 0fdb92d14..53ede682a 100644 --- a/src/client/state/core.ts +++ b/src/client/state/core.ts @@ -94,7 +94,8 @@ export const floor: Slice = { s.issues = v.issues; s.pulls = v.pulls; s.queue = v.queue; - return ['floor', 'project', 'workers', 'issues', 'pulls', 'queue']; + s.alerts = v.alerts; + return ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'alerts']; }, on: { 'worker.update'(s, m) { @@ -129,5 +130,13 @@ export const floor: Slice = { s.queue = m.state; return ['queue']; }, + alerts(s, m) { + s.alerts = m.state; + return ['alerts']; + }, + 'alert.added'(s, m) { + s.alerts = { cards: [m.card, ...s.alerts.cards.filter((c) => c.id !== m.card.id)] }; + return ['alerts']; + }, }, }; diff --git a/src/client/state/store.ts b/src/client/state/store.ts index a526fcab5..65e3d0bc2 100644 --- a/src/client/state/store.ts +++ b/src/client/state/store.ts @@ -8,7 +8,7 @@ // server message and from each floor you arrive on. The store runs the slices in the one order they're // registered in (./slices/index.ts), and that order is the order their topics fire in. -import type { ChatLine, FloorInfo, FloorView, GhIssue, GhPull, GhState, Me, PeerInfo, ProjectInfo, ProjectsDirState, QueueState, QueueTask, RepoChoice, Run, ServerMsg, WorkerInfo } from '../../shared/protocol'; +import type { AlertState, ChatLine, FloorInfo, FloorView, GhIssue, GhPull, GhState, Me, PeerInfo, ProjectInfo, ProjectsDirState, QueueState, QueueTask, RepoChoice, Run, ServerMsg, WorkerInfo } from '../../shared/protocol'; import { randomLook } from '../../shared/avatar'; import { AVATAR_COLORS, type Profile } from './persist'; @@ -39,6 +39,7 @@ export interface Topics { issues: true; pulls: true; queue: true; + alerts: true; } export type Topic = keyof Topics; @@ -110,6 +111,7 @@ export class Store { /** Whether this office can invite teammates (deployed with deploy/aws.sh). */ invites = false; queue: QueueState = { tasks: [], maxWorkers: 0 }; + alerts: AlertState = { cards: [] }; /** Who you're signed in as (see /api/whoami). */ me: Me = { admin: false }; private subs = new Map void>>(); diff --git a/src/client/ui/alerts.css b/src/client/ui/alerts.css new file mode 100644 index 000000000..25a186410 --- /dev/null +++ b/src/client/ui/alerts.css @@ -0,0 +1,10 @@ +.alert-modal{width:min(920px,100%)} +.alert-filters{display:flex;gap:8px;padding:12px 18px;border-bottom:1px solid var(--line);overflow-x:auto} +.alert-board{display:grid;gap:12px;max-height:min(68vh,760px);overflow:auto} +.alert-card{border:1px solid var(--line);border-left:5px solid #4f86f7;border-radius:12px;padding:14px;background:var(--panel)} +.alert-card[data-severity="watch"]{border-left-color:#ffd166} +.alert-card[data-severity="urgent"]{border-left-color:#ef476f} +.alert-head{display:flex;justify-content:space-between;gap:12px;text-transform:uppercase;font-size:.72rem;letter-spacing:.08em;opacity:.8} +.alert-card h3{margin:8px 0 4px}.alert-market{margin:0 0 10px;opacity:.72}.alert-why{padding:12px;border-radius:9px;background:color-mix(in srgb,var(--accent) 12%,transparent)} +.alert-why p,.alert-grid p{margin:5px 0 0}.alert-details{margin-top:10px}.alert-details summary{cursor:pointer;font-weight:700}.alert-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.alert-grid>div{padding:10px;border:1px solid var(--line);border-radius:8px}.alert-grid ul{margin:6px 0 0;padding-left:20px}.alert-links{display:flex;gap:8px;margin-top:12px}.alert-empty{text-align:center;padding:44px 18px;opacity:.75} +@media(max-width:640px){.alert-grid{grid-template-columns:1fr}} diff --git a/src/client/ui/alerts.ts b/src/client/ui/alerts.ts new file mode 100644 index 000000000..18492202b --- /dev/null +++ b/src/client/ui/alerts.ts @@ -0,0 +1,60 @@ +import './alerts.css'; +import type { AlertCard, AlertTopic } from '../../shared/protocol'; +import { store } from '../state'; +import { h, openModal } from './dom'; + +export type AlertFilter = 'all' | AlertTopic; + +export function filterAlerts(cards: AlertCard[], topic: AlertFilter): AlertCard[] { + return topic === 'all' ? cards : cards.filter((card) => card.topic === topic); +} + +const TOPICS: { id: AlertFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'general', label: 'General' }, + { id: 'convergence', label: 'Convergence' }, + { id: 'insider', label: 'Insider' }, + { id: 'entry-watch', label: 'Entry watch' }, +]; + +function alertCard(card: AlertCard): HTMLElement { + const details = h( + 'details.alert-details', + {}, + h('summary', {}, 'Evidence & invalidation'), + h('div.alert-grid', {}, + h('div', {}, h('b', {}, 'Evidence'), h('ul', {}, ...card.layers.evidence.map((item) => h('li', {}, item)))), + h('div', {}, h('b', {}, 'Freshness'), h('p', {}, card.layers.freshness)), + h('div', {}, h('b', {}, 'Coverage'), h('p', {}, card.layers.coverage)), + h('div', {}, h('b', {}, 'Invalidation'), h('p', {}, card.layers.invalidation)), + ), + card.links.length ? h('div.alert-links', {}, ...card.links.map((link) => h('a.btn', { href: link.url, target: '_blank', rel: 'noopener' }, link.label))) : null, + ); + return h( + 'article.alert-card', + { 'data-severity': card.severity }, + h('div.alert-head', {}, h('span.alert-topic', {}, card.topic), h('span.alert-severity', {}, card.severity)), + h('h3', {}, card.title), + card.token || card.chain ? h('p.alert-market', {}, [card.token, card.chain].filter(Boolean).join(' Β· ')) : null, + h('div.alert-why', {}, h('b', {}, 'WHY NOW'), h('p', {}, card.layers.whyNow)), + details, + ); +} + +export function openAlerts() { + let filter: AlertFilter = 'all'; + const body = h('div.body.alert-board'); + const filters = h('nav.alert-filters', { 'aria-label': 'Alert topics' }); + const el = h('div.modal.alert-modal', { role: 'dialog', 'aria-label': 'Dewa Information Alpha alerts' }, h('header', {}, h('h2', {}, 'πŸ“‘ Dewa Information Alpha'), h('span.grow')), filters, body); + let modal: ReturnType; + + const render = () => { + filters.replaceChildren(...TOPICS.map(({ id, label }) => h('button.btn', { type: 'button', class: filter === id ? 'primary' : '', onclick: () => { filter = id; render(); } }, `${label} (${filterAlerts(store.alerts.cards, id).length})`))); + const cards = filterAlerts(store.alerts.cards, filter); + body.replaceChildren(...(cards.length ? cards.map(alertCard) : [h('div.alert-empty', {}, h('b', {}, 'No alerts yet'), h('p', {}, 'Fresh research signals will appear here with evidence, coverage, freshness, and invalidation.'))])); + }; + + render(); + const off = store.on('alerts', render); + modal = openModal(el, { doing: 'reviewing Dewa Alpha alerts', reading: true, onClose: off }); +} diff --git a/src/client/ui/lobby.css b/src/client/ui/lobby.css new file mode 100644 index 000000000..d2c586875 --- /dev/null +++ b/src/client/ui/lobby.css @@ -0,0 +1,2 @@ +.lobby-modal{width:min(980px,100%)} +.lobby-hero{padding:22px 24px;background:linear-gradient(120deg,color-mix(in srgb,var(--accent) 18%,var(--panel)),var(--panel))}.lobby-hero h2{margin:2px 0;font-size:1.65rem}.lobby-hero p{margin:3px 0}.lobby-kicker{font-size:.72rem;font-weight:800;letter-spacing:.14em;opacity:.7}.lobby-identity{display:grid;justify-items:end;gap:6px}.lobby-role{padding:5px 9px;border:1px solid var(--line);border-radius:999px;font-size:.75rem;font-weight:800}.lobby-integrations{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.lobby-status.warn{color:#f0a44b}.lobby-body{display:grid;grid-template-columns:1.2fr .8fr;gap:18px}.lobby-body h3{margin-top:0}.lobby-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.lobby-stat{display:grid;grid-template-columns:auto 1fr;gap:2px 9px;padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.lobby-stat>span{grid-row:span 2;font-size:1.4rem}.lobby-stat>b{font-size:1.3rem}.lobby-stat small{opacity:.7}.lobby-stat.warn{border-color:#ffd166}.lobby-stat.urgent{border-color:#ef476f}.lobby-activity{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.lobby-status{padding:7px 10px;border:1px solid var(--line);border-radius:999px}.lobby-status.on{border-color:#06d6a0}.lobby-status.urgent{border-color:#ef476f}.lobby-actions{display:grid;gap:9px}.lobby-action{display:grid;grid-template-columns:auto 1fr;text-align:left;gap:12px;align-items:center;width:100%;padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:inherit;cursor:pointer}.lobby-action:hover{border-color:var(--accent);transform:translateY(-1px)}.lobby-action>span{font-size:1.5rem}.lobby-action b,.lobby-action small{display:block}.lobby-action small{margin-top:3px;opacity:.7}@media(max-width:700px){.lobby-body{grid-template-columns:1fr}.lobby-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.lobby-modal{height:min(94vh,780px)}.lobby-body{overflow:auto}.lobby-action{min-height:62px}} \ No newline at end of file diff --git a/src/client/ui/lobby.ts b/src/client/ui/lobby.ts new file mode 100644 index 000000000..0f89ec313 --- /dev/null +++ b/src/client/ui/lobby.ts @@ -0,0 +1,88 @@ +import './lobby.css'; +import { councilAccess } from '../../shared/access'; +import { store } from '../state'; +import { h, openModal } from './dom'; + +interface BriefInput { + workers: { status: string }[]; + queue: { status: string }[]; + alerts: { severity: string }[]; + issues: { state: string }[]; + pulls: { state: string }[]; + council: { status: string } | null; +} + +type IntegrationState = 'LIVE' | 'CONFIGURED' | 'OFFLINE' | 'PLANNED'; + +interface IntegrationInput { + floor: string | null; + project: { agentProviders: string[] } | null; + workers: { provider?: string; model?: string; status: string }[]; + /** The floor's alert board: the research feed's own cards are the evidence it is wired. */ + alerts?: { kind: string }[]; +} + +/** Runtime truth only: configured adapters are not reported as live workers. */ +export function integrationStatus(input: IntegrationInput): Record<'server' | 'research' | 'qwen' | 'muse' | 'remotion', IntegrationState> { + const configured = new Set(input.project?.agentProviders ?? []); + const active = input.workers.filter((w) => w.status !== 'done' && w.status !== 'exited'); + const qwenLive = active.some((w) => w.provider === 'hermes' && /qwen3\.5/i.test(w.model ?? '')); + const museLive = active.some((w) => w.provider === 'muse'); + // Research is live only when its own cards reached the board β€” the report produced them. + const researchLive = (input.alerts ?? []).some((card) => card.kind === 'insider'); + return { + server: input.floor && input.project ? 'LIVE' : 'OFFLINE', + research: researchLive ? 'LIVE' : 'OFFLINE', + qwen: qwenLive ? 'LIVE' : configured.has('hermes') ? 'CONFIGURED' : 'OFFLINE', + muse: museLive ? 'LIVE' : configured.has('muse') ? 'CONFIGURED' : 'OFFLINE', + remotion: 'PLANNED', + }; +} + +export function dailyBrief(input: BriefInput) { + return { + activeWorkers: input.workers.filter((w) => w.status !== 'done' && w.status !== 'exited').length, + waitingWorkers: input.workers.filter((w) => w.status === 'needs_input').length, + openTasks: input.queue.filter((task) => task.status !== 'done').length, + alerts: input.alerts.length, + urgentAlerts: input.alerts.filter((alert) => alert.severity === 'urgent').length, + openIssues: input.issues.filter((issue) => issue.state === 'OPEN').length, + openPulls: input.pulls.filter((pull) => pull.state === 'OPEN').length, + councilActive: input.council?.status === 'running', + }; +} + +export interface LobbyActions { + council(): void; + canvas(): void; + alerts(): void; +} + +export function openLobby(actions: LobbyActions) { + const access = councilAccess(store.me); + const stats = h('div.lobby-stats'); + const activity = h('div.lobby-activity'); + const integrations = h('div.lobby-integrations'); + const card = (icon: string, value: number | string, label: string, tone = '') => h('div.lobby-stat', { class: tone }, h('span', {}, icon), h('b', {}, String(value)), h('small', {}, label)); + const action = (icon: string, title: string, copy: string, run: () => void) => h('button.lobby-action', { type: 'button', onclick: run }, h('span', {}, icon), h('div', {}, h('b', {}, title), h('small', {}, copy))); + const el = h('div.modal.lobby-modal', { role: 'dialog', 'aria-label': 'Dewa Information Alpha lobby' }, + h('header.lobby-hero', {}, h('div', {}, h('p.lobby-kicker', {}, 'LOKIDEWA COUNCIL'), h('h2', {}, 'Dewa Information Alpha'), h('p', {}, 'Research, challenge evidence, decide, execute.')), h('div.lobby-identity', {}, h('span.lobby-role', {}, access.role))), + h('div.body.lobby-body', {}, h('section', {}, h('h3', {}, 'Today’s brief'), stats, activity, h('h3', {}, 'Integrations'), integrations), h('section.lobby-actions', {}, action('πŸ›οΈ', 'Council war room', 'Call or observe a decision session', actions.council), action('🧠', 'Research canvas', 'Map narratives, evidence, and hypotheses', actions.canvas), action('πŸ“‘', 'Alpha alerts', 'Review WHY NOW cards and invalidation', actions.alerts), action('πŸ“±', 'Mobile companion', 'Open fast 2D operations view', () => location.assign('/lite')))), + ); + let modal: ReturnType; + for (const button of el.querySelectorAll('.lobby-action')) { + const old = button.onclick; + button.onclick = (event) => { modal.close(); old?.call(button, event); }; + } + const render = () => { + const brief = dailyBrief({ workers: [...store.workers.values()], queue: store.queue.tasks, alerts: store.alerts.cards, issues: store.issues.items, pulls: store.pulls.items, council: store.meeting.current }); + stats.replaceChildren(card('πŸ€–', brief.activeWorkers, 'active agents'), card('πŸ™‹', brief.waitingWorkers, 'need you', brief.waitingWorkers ? 'warn' : ''), card('πŸ“‹', brief.openTasks, 'open tasks'), card('πŸ“‘', brief.alerts, 'signals', brief.urgentAlerts ? 'urgent' : ''), card('πŸ“Œ', brief.openIssues, 'open issues'), card('πŸ”€', brief.openPulls, 'open PRs')); + activity.replaceChildren(h('span.lobby-status', { class: brief.councilActive ? 'on' : '' }, brief.councilActive ? 'πŸ›οΈ Council session active' : 'πŸ›οΈ Council room ready'), brief.urgentAlerts ? h('span.lobby-status.urgent', {}, `${brief.urgentAlerts} urgent signal${brief.urgentAlerts === 1 ? '' : 's'}`) : h('span.lobby-status', {}, 'No urgent signals')); + const connected = integrationStatus({ floor: store.floor, project: store.project, workers: [...store.workers.values()], alerts: store.alerts.cards }); + integrations.replaceChildren(...Object.entries(connected).map(([name, state]) => h('span.lobby-status', { class: state === 'LIVE' ? 'on' : state === 'OFFLINE' ? 'warn' : '' }, `${name}: ${state}`))); + }; + render(); + const offs = ['workers', 'queue', 'alerts', 'issues', 'pulls', 'meeting'].map((topic) => store.on(topic as never, render)); + modal = openModal(el, { doing: 'reviewing today’s Dewa Alpha brief', reading: true, onClose: () => offs.forEach((off) => off()) }); + return modal; +} diff --git a/src/client/ui/meeting.ts b/src/client/ui/meeting.ts index b3596ffde..9857e51e1 100644 --- a/src/client/ui/meeting.ts +++ b/src/client/ui/meeting.ts @@ -1,9 +1,10 @@ import './meeting.css'; import { MEETING_PATTERNS, MEETING_PATTERN_IDS, fixedRounds, meetingSpend, meetingStage, outputProblem, slugify } from '../../shared/meetings'; -import { fmtTokens, type Meeting, type MeetingPattern, type MeetingTurn } from '../../shared/protocol'; +import { meetingPresence } from '../../shared/presence'; +import { fmtTokens, type Meeting, type MeetingPattern, type MeetingRecord, type MeetingTurn } from '../../shared/protocol'; import type { Net } from '../net'; import { store } from '../state'; -import { h, openModal, timeAgo, toast, STATUS_LABEL, type Modal } from './dom'; +import { h, openModal, setDoing, timeAgo, toast, STATUS_LABEL, type Modal } from './dom'; import { confirmDialog } from './prompt'; import { providerPicker } from './provider'; import { officePrompt } from './prompts'; @@ -32,6 +33,7 @@ export function issueMeeting(n: number, title: string): MeetingPreset { const PART_LABEL: Record = { waiting: '⏳ up next', sent: 'πŸ“¨ handed over', working: 'πŸ’¬ on it', done: 'βœ… written' }; + /** * The meeting room's window. With a meeting at the table it shows how it's going (and stops it, or * clears the table once it's over); otherwise, or with a preset from an issue or a PR, it's the form @@ -43,15 +45,32 @@ export function openMeeting(net: Net, actions: MeetingActions, preset?: MeetingP const body = h('div.body.meeting'); const foot = h('footer'); const el = h('div.modal.meeting-window', { role: 'dialog', 'aria-label': 'Meeting room' }, h('header', {}, title, close), body, foot); - let view: 'status' | 'form' = preset || !store.meeting.current ? 'form' : 'status'; + let view: 'status' | 'form' | 'replay' = preset || !store.meeting.current ? 'form' : 'status'; + let selectedRecord: MeetingRecord | null = null; let form: ReturnType | null = null; const render = () => { + if (view === 'replay' && selectedRecord) { + form = null; + title.textContent = 'βͺ Council replay'; + setDoing(modal, meetingPresence(null, selectedRecord.title)); + renderReplay(selectedRecord, body, foot, () => { + view = store.meeting.current ? 'status' : 'form'; + selectedRecord = null; + render(); + }); + return; + } if (view === 'status' && store.meeting.current) { form = null; title.textContent = '🀝 Meeting room'; + setDoing(modal, meetingPresence(store.meeting.current)); renderStatus(store.meeting.current, body, foot, net, actions, () => { view = 'form'; render(); + }, (record) => { + selectedRecord = record; + view = 'replay'; + render(); }); return; } @@ -61,6 +80,7 @@ export function openMeeting(net: Net, actions: MeetingActions, preset?: MeetingP render(); }); title.textContent = '🀝 Call a meeting'; + setDoing(modal, meetingPresence(null)); body.replaceChildren(form.body); foot.replaceChildren(...form.foot); } @@ -72,7 +92,7 @@ export function openMeeting(net: Net, actions: MeetingActions, preset?: MeetingP render(); } -function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net, actions: MeetingActions, callAnother: () => void) { +function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net, actions: MeetingActions, callAnother: () => void, replay: (record: MeetingRecord) => void) { const p = MEETING_PATTERNS[m.pattern]; const running = m.status === 'running'; const pill = h('span.pill', { class: running ? 'working' : m.status === 'done' ? 'done' : 'needs_input' }, running ? 'in a meeting' : m.status); @@ -106,7 +126,7 @@ function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net seats, h('div.meeting-out', {}, h('div.meeting-out-head', {}, h('b', {}, 'πŸ“„ '), h('code', {}, m.output), where, review), h('pre.meeting-preview', {}, m.preview?.trim() ? m.preview : running ? 'Nothing written yet.' : 'Nothing was written.')), store.meeting.past.length - ? h('details.meeting-past', {}, h('summary', {}, `Earlier meetings (${store.meeting.past.length})`), h('ul', {}, ...store.meeting.past.map((r) => h('li', { title: `Called by ${r.calledBy}` }, h('b', {}, r.title), h('div.muted', {}, r.summary))))) + ? h('details.meeting-past', {}, h('summary', {}, `Earlier meetings (${store.meeting.past.length})`), h('ul', {}, ...store.meeting.past.map((r) => h('li', {}, h('button.btn.meeting-replay', { type: 'button', title: `Replay meeting called by ${r.calledBy}`, onclick: () => replay(r) }, h('b', {}, `βͺ ${r.title}`), h('span.muted', {}, r.summary)))))) : null, ), ); @@ -122,6 +142,27 @@ function renderStatus(m: Meeting, body: HTMLElement, foot: HTMLElement, net: Net ); } +function renderReplay(record: MeetingRecord, body: HTMLElement, foot: HTMLElement, back: () => void) { + const pattern = MEETING_PATTERNS[record.pattern]; + const decision = record.decision; + const list = (title: string, items: string[]) => h('section', {}, h('b', {}, title), items.length ? h('ul', {}, ...items.map((item) => h('li', {}, item))) : h('p.muted', {}, 'None recorded')); + body.replaceChildren( + h('div.meeting-head', {}, h('span.pill.done', {}, record.status), h('b', {}, `${pattern.icon} ${pattern.label}`), h('span.meeting-title', {}, record.title)), + h('p.meeting-line', {}, `Called by ${record.calledBy} Β· ${record.rounds} rounds Β· finished ${timeAgo(new Date(record.finishedAt).toISOString())}`), + h('section.meeting-out', {}, h('b', {}, 'Mandate'), h('p', {}, record.prompt)), + h('ul.meeting-seats', {}, ...record.seats.map((seat) => h('li', {}, h('b', {}, seat.role), h('span.muted', {}, seat.workerName ?? 'worker')))), + decision ? h('div.council-record', {}, + h('section.alert-why', {}, h('b', {}, 'DECISION'), h('p', {}, decision.decision)), + h('section.alert-why', {}, h('b', {}, 'WHY NOW'), h('p', {}, decision.whyNow)), + list('Evidence for', decision.evidenceFor), + list('Evidence against', decision.evidenceAgainst), + list('Invalidation', decision.invalidation), + h('div.alert-grid', {}, h('div', {}, h('b', {}, 'Confidence band'), h('p', {}, decision.confidenceBand)), h('div', {}, h('b', {}, 'Owner'), h('p', {}, decision.owner)), h('div', {}, h('b', {}, 'Next review'), h('p', {}, decision.nextReview))), + ) : h('div.meeting-out', {}, h('b', {}, `πŸ“„ ${record.output}`), h('pre.meeting-preview', {}, record.preview?.trim() || 'No archived output.')), + ); + foot.replaceChildren(h('span.grow', {}, 'Read-only replay. No agents are rerun.'), h('button.btn.primary', { type: 'button', onclick: back }, '← Back to war room')); +} + const present = (...xs: (Node | null)[]): Node[] => xs.filter((x): x is Node => x !== null); /** The form that calls a meeting: the pattern, what it's about, who sits down, the output, the round limit. */ diff --git a/src/client/world/office/meeting-room.ts b/src/client/world/office/meeting-room.ts index 160175391..607b25c74 100644 --- a/src/client/world/office/meeting-room.ts +++ b/src/client/world/office/meeting-room.ts @@ -109,13 +109,18 @@ export function buildMeetingRoom(group: THREE.Group, colliders: Collider[], inte for (const [leaf, x0] of leaves) leaf.position.x = x0 + Math.sign(x0 - dx) * e * (half - 0.06); }, }); - const label = textPlane('🀝 Meeting room', { bg: '#2b2d42', color: '#fffaf3', size: 56, border: '#fffaf3' }); - label.scale.multiplyScalar(0.62); + const label = textPlane('πŸ›οΈ Dewa Information Alpha', { bg: '#111827', color: '#fffaf3', size: 54, border: '#ffd166' }); + label.scale.multiplyScalar(0.58); // In front of the glass wall's frame (out to R.minZ - 0.08) and the sliding leaves (to R.minZ - 0.11), // which it runs across once its text is wider than the door. label.position.set(dx, 2.52, R.minZ - 0.13); label.rotation.y = Math.PI; group.add(label); + const sublabel = textPlane('Alpha war room Β· council Β· research Β· execution', { bg: '#fffaf3', color: '#2b2d42', size: 34, border: '#2b2d42' }); + sublabel.scale.multiplyScalar(0.42); + sublabel.position.set(dx, 2.18, R.minZ - 0.135); + sublabel.rotation.y = Math.PI; + group.add(sublabel); // The table, on two pedestals, and its chairs. const table = new THREE.Group(); @@ -150,6 +155,25 @@ export function buildMeetingRoom(group: THREE.Group, colliders: Collider[], inte const read: Interactable = { kind: 'meeting', x: b.x, z: b.z - 1.4, radius: 2.4 }; interactables.push(read); frame.userData.interact = read; + const boardTitle = textPlane('WHY NOW / EVIDENCE / INVALIDATION', { bg: '#111827', color: '#ffd166', size: 34, border: '#ffd166' }); + boardTitle.scale.multiplyScalar(0.5); + boardTitle.position.set(b.x, b.y + b.height / 2 + 0.22, b.z - 0.02); + boardTitle.rotation.y = Math.PI; + group.add(boardTitle); + + const tags = [ + ['Wallet Flow', '#118ab2'], + ['Alpha Queue', '#ef476f'], + ['Risk Desk', '#ffd166'], + ['Next Actions', '#06d6a0'], + ] as const; + tags.forEach(([name, color], i) => { + const tag = textPlane(name, { bg: color, color: '#111827', size: 30, border: '#111827' }); + tag.scale.multiplyScalar(0.36); + tag.position.set(R.minX + 0.05, 1.15 + i * 0.32, R.minZ + 1.1); + tag.rotation.y = Math.PI / 2; + group.add(tag); + }); // The panel on the glass beside the door, like a room-booking screen: what's on, the round, the // tokens, and the summary once it's over. Beside the door rather than past it, so the board shows. diff --git a/src/server/alerts.ts b/src/server/alerts.ts new file mode 100644 index 000000000..6952bd789 --- /dev/null +++ b/src/server/alerts.ts @@ -0,0 +1,42 @@ +import type { AlertCard, AlertInput, AlertState, AlertTopic } from '../shared/protocol/alerts.js'; + +export type { AlertCard, AlertInput, AlertState } from '../shared/protocol/alerts.js'; + +export function topicForAlert(input: Pick): AlertTopic { + if (input.kind === 'accumulation') return 'general'; + return input.kind; +} + +export class AlertBoard { + private cards: AlertCard[] = []; + + constructor(private readonly limit = 100) {} + + state(): AlertState { + return { cards: this.cards.map((c) => ({ ...c, layers: { ...c.layers }, links: c.links.map((l) => ({ ...l })) })) }; + } + + add(input: AlertInput, now = Date.now()): AlertCard { + const card: AlertCard = { + id: `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + kind: input.kind, + topic: topicForAlert(input), + title: input.title, + token: input.token, + chain: input.chain, + severity: input.severity, + createdAt: now, + layers: { + whyNow: input.whyNow, + evidence: input.evidence.slice(), + freshness: input.freshness, + coverage: input.coverage, + invalidation: input.invalidation, + }, + links: input.links?.map((l) => ({ ...l })) ?? [], + }; + this.cards.unshift(card); + this.cards.length = Math.min(this.cards.length, this.limit); + return card; + } +} diff --git a/src/server/council-parts.ts b/src/server/council-parts.ts new file mode 100644 index 000000000..efb8d1f5a --- /dev/null +++ b/src/server/council-parts.ts @@ -0,0 +1,71 @@ +// The LokiDewa council's parts of each round, kept out of meetings.ts so the room's +// own file stays under its size ceiling (see tests/size.test.ts). The room calls this +// with its `say`, so the prompts stay rewritable in βš™οΈ Settings like every other. +import path from 'node:path'; +import { slugify } from '../shared/meetings.js'; +import type { Meeting } from '../shared/protocol.js'; +import type { PromptId, PromptVars } from '../shared/prompts.js'; + +/** One councillor's part of a step, the same shape the room's own plan returns. */ +export interface CouncilPart { + seat: number; + doing: string; + file: string; + ask: string; +} + +/** + * The council's rounds: councillors brief from their mandates, challenge each other, and + * the chair writes the decision LokiDewa acts on. Returns null when that step has no parts, + * as the room's own plan does. + */ +export function councilParts( + m: Meeting, + round: number, + step: number, + cwd: string, + notes: string, + say: (id: PromptId, vars?: PromptVars) => string, +): CouncilPart[] | null { + if (step > 1) return null; + const all = m.seats.map((_, i) => i); + const council = all.slice(1); + // Notes are relative to the meeting's own folder; the prompts want full paths. + const A = (rel: string) => path.join(cwd, rel); + const note = (r: number, i: number) => `${notes}/r${r}-${i + 1}-${slugify(m.seats[i].role, 24)}.md`; + const notes_ = (r: number, seats: number[]) => seats.map((i) => A(note(r, i))).join(', '); + const last = round >= m.rounds || m.lastRound === round; + + // The last round is the chair's alone: the decision, and nothing beside it. + if (last) { + return [ + { + seat: 0, + doing: 'writing the decision', + file: m.output, + ask: say('meeting.council.decide', { title: m.title, briefings: notes_(round - 1, all), notes: A(notes), output: A(m.output) }), + }, + ]; + } + + // Round 1: each councillor briefs from their own mandate. + if (round === 1) { + return council.map((i) => ({ + seat: i, + doing: 'briefing their mandate', + file: note(1, i), + ask: say('meeting.council.brief', { role: m.seats[i].role, question: m.prompt, file: A(note(1, i)) }), + })); + } + + // A middle round: every councillor challenges the others, and the chair weighs them last. + return all.map((i) => ({ + seat: i, + doing: 'challenging', + file: note(round, i), + ask: + i === 0 + ? say('meeting.council.weigh', { role: m.seats[0].role, briefings: notes_(round - 1, council), challenges: notes_(round - 1, council), file: A(note(round, i)) }) + : say('meeting.council.challenge', { role: m.seats[i].role, otherBriefings: notes_(round - 1, council.filter((j) => j !== i)), file: A(note(round, i)) }), + })); +} diff --git a/src/server/floor.ts b/src/server/floor.ts index ad3bd25fd..eaba9ba76 100644 --- a/src/server/floor.ts +++ b/src/server/floor.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync } from 'node:fs'; +import { mkdirSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; import path from 'node:path'; import type { ChangesState, FloorInfo, PeerInfo, ProjectInfo, ServerMsg, WorkerInfo } from '../shared/protocol.js'; import { isBusy } from '../shared/status.js'; @@ -21,6 +22,8 @@ import { Jail } from './jail.js'; import { Garage } from './garage.js'; import { Jukebox } from './jukebox.js'; import { Whiteboard } from './whiteboard.js'; +import { AlertBoard } from './alerts.js'; +import { loadResearchAlerts } from './research-feed.js'; import { MeetingRoom } from './meetings.js'; import { Worktrees, type WorktreeCleanup } from './worktrees.js'; import { landedWork, landedWorkers, type Landed } from './leave-on-merge.js'; @@ -115,6 +118,8 @@ export class Floor { readonly workers: WorkerManager; readonly github: GitHub; readonly queue: TaskQueue; + /** LokiDewa two-layer alert cards on this floor. */ + readonly alerts = new AlertBoard(); readonly changes: Changes; readonly decor: Decor; /** The signs over its desks, and how far its back office is built out. */ @@ -310,6 +315,15 @@ export class Floor { this.whiteboard = new Whiteboard(dataDir); this.ready = this.workers.start(); + // LokiDewa: load whale research cards from the report output file. + // Defaults to staging path; override with DEWA_RESEARCH_REPORT env var. + const reportPath = process.env.DEWA_RESEARCH_REPORT + || path.join(homedir(), 'DewaResearch/qwen-report-stage/research_report.txt'); + if (existsSync(reportPath)) { + const n = loadResearchAlerts(this.alerts, reportPath); + if (n > 0) console.log(`[floor] loaded ${n} research cards from ${reportPath}`); + } + void this.github.refresh(); // A floor with people on it, or work under way, keeps its boards fresh; the others check in now and then. this.timer = setInterval(() => { diff --git a/src/server/meetings.ts b/src/server/meetings.ts index 31c8919d4..2ee0e7b96 100644 --- a/src/server/meetings.ts +++ b/src/server/meetings.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { promisify } from 'node:util'; import { MEETING_SEATS } from '../shared/layout.js'; import { MEETING_NOTES_DIR, MEETING_PATTERNS, isMeetingPattern, meetingRecord, outputProblem, slugify } from '../shared/meetings.js'; +import { councilParts } from './council-parts.js'; import { isAgentEffort, isAgentProvider, tokensOf, type AgentChoice, type AgentEffort, type AgentProvider, type Meeting, type MeetingRecord, type MeetingRequest, type MeetingState, type MeetingTurn, type WorkerInfo, type WorkerStatus } from '../shared/protocol.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; import { providerMeta, takesEffort, takesModel } from '../shared/providers.js'; @@ -518,6 +519,7 @@ export class MeetingRoom { mapreduce: `Round 1: each mapper does the task over its own parts. Round 2: the ${head} combines what they found into one result.`, redblue: `Each round the Red team attacks the change (bugs, security holes, edge cases) and the Blue team fixes what holds up. The ${head} writes it all up in the last round, which comes early if Red finds nothing more.`, review: `Round 1: each reviewer reviews the pull request through their own lens. Round 2: the ${head} merges the reviews into one, which the office posts on the pull request.`, + council: `Round 1: each councillor briefs the council from their own mandate. Each round after that until the last: every councillor challenges the others' briefings and revises their own; the ${head} weighs them last. Last round: the ${head} writes the decision LokiDewa acts on.`, }; const where = !m.worktree ? `You're in the project's folder, which other people use too: don't commit, push or switch branches.` @@ -629,6 +631,8 @@ export class MeetingRoom { } return [{ seat: 0, doing: 'writing the review', file: m.output, ask: this.say('meeting.review.combine', { findings: notes(1, all), exampleRole: m.seats[1]?.role ?? 'Security', output: A(m.output) }) }]; } + case 'council': + return councilParts(m, round, step, this.cwd(m), m.notes, (id, vars) => this.say(id, vars)); } } diff --git a/src/server/providers/hermes.ts b/src/server/providers/hermes.ts new file mode 100644 index 000000000..62e231f20 --- /dev/null +++ b/src/server/providers/hermes.ts @@ -0,0 +1,45 @@ +// LokiDewa Council: a Hermes Agent worker. Hermes is installed on the office machine +// (hermes-agent, or the venv at ~/.hermes/hermes-agent); a worker is one +// `hermes chat` session whose ID the office keeps, so a model swap mid-council is a +// re-launch against the same session β€” the briefcase, not the brain, is what persists. +import type { ProviderAdapter } from './types.js'; + +/** A Hermes session's ID, kept by the office and resumed on re-launch. */ +export interface HermesState { + sessionId?: string; +} + +export const hermes: ProviderAdapter = { + id: 'hermes', + // Hermes sets these on its own children; a worker never inherits them. + scrubEnv: ['HERMES_SESSION', 'HERMES_WORKTREE'], + scrubPrefixes: ['HERMES_'], + createState: () => ({}), + + // `hermes chat` flags: -m picks the model, -q seeds the first turn, --resume continues a + // session, --pass-session-id prints the id the office keeps, --cli stays headless in the PTY. + // A reasoning effort goes in as Hermes's own --reasoning level, when one was picked. + launch: ({ args, prompt, resumeSessionId, cwd, h }) => { + const plan: LaunchPlan = { args: [...args] }; + if (h.info.model) plan.args.push('-m', h.info.model); + if (h.info.effort) plan.args.push('--reasoning', h.info.effort); + plan.args.push('--cli', '--pass-session-id', '--in', cwd); + if (resumeSessionId) plan.args.push('--resume', resumeSessionId); + if (prompt) plan.args.push('-q', prompt); + return plan; + }, + + transport: 'pty', + bootHint: 'Booting Hermes β€” waiting for its first tool call', + titleNoise: /^hermes( chat)?$/i, + // A session that has been pruned or imported can't be resumed: start fresh. + freshIfResumeFails: true, + + // Hermes speaks Claude Code's hook route when configured to; the office reads its + // transcript for usage either way. + hooksAs: 'claude', + usage: { transcript: true }, + namesTasks: true, +}; + +type LaunchPlan = import('./types.js').LaunchPlan; diff --git a/src/server/providers/index.ts b/src/server/providers/index.ts index 1158d0a0a..7868d10a3 100644 --- a/src/server/providers/index.ts +++ b/src/server/providers/index.ts @@ -7,6 +7,7 @@ import { cursor } from './cursor.js'; import { custom } from './custom.js'; import { dsh } from './dsh.js'; import { grok } from './grok.js'; +import { hermes } from './hermes.js'; import { muse } from './muse.js'; import { opencode } from './opencode.js'; import { pi } from './pi.js'; @@ -24,6 +25,7 @@ export const PROVIDERS: Record = { pi, cursor, custom, + hermes, }; /** The hook route /hooks/`route` serves, when it's one (see ProviderAdapter.hook). */ diff --git a/src/server/research-feed.ts b/src/server/research-feed.ts new file mode 100644 index 000000000..3b051a43e --- /dev/null +++ b/src/server/research-feed.ts @@ -0,0 +1,54 @@ +// LokiDewa research feed: turns the whale research report into WHY NOW alert cards. +// Read-only: it parses text the report already produced. It never reads the DB, never sends +// Telegram, and never turns an INSUFFICIENT verdict into a recommendation. +import { readFileSync } from 'node:fs'; +import type { AlertInput } from '../shared/protocol/alerts.js'; + +const REPORT = /LAPORAN RISET WHALE<\/b> β€” (\d{4}-\d{2}-\d{2}) \d{2}:\d{2} UTC/; +const ENTITY = /^β€’ (\S{6,}): n=(\d+) \((\d+) token\), median net ([+-][\d.]+)%.*?(?:β€” (FOLLOW_ELIGIBILITY_REVIEW|NOT_DISTINGUISHABLE_FROM_BASELINE|INSUFFICIENT))?\s*$/; +// The protocol has no SUPPORTED verdict; anything claiming it is not this report. +const BANNED = /SUPPORTED|probabilitas|menang minimal \d+% win/i; + +/** Research cards for the alert board, or [] when the report is missing or makes claims it must not. */ +export function researchCards(text: string): AlertInput[] { + const head = REPORT.exec(text); + if (!head) return []; + const freshness = head[1]; + if (BANNED.test(text)) return []; + const cards: AlertInput[] = []; + for (const line of text.split('\n')) { + const m = ENTITY.exec(line); + if (!m) continue; + const [, wallet, n, tokens, median, verdict = 'INSUFFICIENT'] = m; + cards.push({ + kind: 'insider', + severity: 'info', + title: `${wallet}: median net ${median}%`, + token: wallet, + whyNow: `Shortlist entity moved: median net ${median}% over ${n} observations on ${tokens} token(s).`, + evidence: [`n=${n} (${tokens} token)`, `median net ${median}%`], + freshness, + coverage: `n=${n} follower outcomes`, + // A verdict other than FOLLOW_ELIGIBILITY_REVIEW is evidence of nothing yet. + invalidation: `Evidence state: ${verdict}. Not a recommendation.`, + links: [], + }); + } + return cards; +} + +/** + * Reads `reportPath` and adds its cards to the floor's board. Returns how many it added. + * Missing or unreadable report: nothing changes, and the board keeps what it had. + */ +export function loadResearchAlerts(board: { add(input: AlertInput): unknown }, reportPath: string): number { + let text: string; + try { + text = readFileSync(reportPath, 'utf8'); + } catch { + return 0; + } + const cards = researchCards(text); + for (const card of cards) board.add(card); + return cards.length; +} diff --git a/src/server/ws/handlers/alerts.ts b/src/server/ws/handlers/alerts.ts new file mode 100644 index 000000000..a6f0c6771 --- /dev/null +++ b/src/server/ws/handlers/alerts.ts @@ -0,0 +1,7 @@ +import type { AlertState } from '../../../shared/protocol.js'; +import type { Floor } from '../../floor.js'; +import type { Ctx } from '../../office/context.js'; + +export function alertsView(_ctx: Ctx, floor: Floor | undefined): AlertState { + return floor?.alerts.state() ?? { cards: [] }; +} diff --git a/src/server/ws/handlers/index.ts b/src/server/ws/handlers/index.ts index dd119c2ea..0e106337e 100644 --- a/src/server/ws/handlers/index.ts +++ b/src/server/ws/handlers/index.ts @@ -2,6 +2,7 @@ // floor. A new feature adds its handler file and a line here. import type { ClientMsg } from '../../../shared/protocol.js'; import { accountsHandlers } from './accounts.js'; +import { alertsView } from './alerts.js'; import { ballHandlers, ballHooks, ballView } from './ball.js'; import { cabinetHandlers, cabinetHooks, cabinetView } from './cabinet.js'; import { carHandlers, carHooks, carsView } from './car.js'; @@ -62,6 +63,7 @@ export const views: ViewPieces = { issues: issuesView, pulls: pullsView, queue: queueView, + alerts: alertsView, decor: decorView, plan: planView, services: servicesView, diff --git a/src/shared/access.ts b/src/shared/access.ts new file mode 100644 index 000000000..fdeef0db6 --- /dev/null +++ b/src/shared/access.ts @@ -0,0 +1,17 @@ +export type CouncilRole = 'Owner' | 'Research Partner' | 'Guest' | 'Agent'; + +export interface CouncilCapabilities { + role: CouncilRole; + manage: boolean; + research: boolean; + execute: boolean; +} + +/** Friendly role names derived from the office's enforced admin/member session model. */ +export function councilAccess(me: { admin: boolean; account?: { role: string } }): CouncilCapabilities { + if (me.admin || me.account?.role === 'admin') return { role: 'Owner', manage: true, research: true, execute: true }; + if (me.account?.role === 'member') return { role: 'Research Partner', manage: false, research: true, execute: true }; + return { role: 'Guest', manage: false, research: false, execute: false }; +} + +export const agentAccess: CouncilCapabilities = { role: 'Agent', manage: false, research: true, execute: true }; diff --git a/src/shared/meetings.ts b/src/shared/meetings.ts index bb6c9d1a4..a4d6ff801 100644 --- a/src/shared/meetings.ts +++ b/src/shared/meetings.ts @@ -1,7 +1,34 @@ // The meeting room's patterns: how 2–5 workers at the table work on one question or task together. // The server runs them (server/meetings.ts); the client offers them when a meeting is called. -import { fmtCost, fmtTokens, type Meeting, type MeetingPattern, type MeetingRecord } from './protocol.js'; +import { fmtCost, fmtTokens, type CouncilDecisionRecord, type Meeting, type MeetingPattern, type MeetingRecord } from './protocol.js'; + +/** Parses the council's fixed evidence-first Markdown contract without inventing missing content. */ +export function parseCouncilDecision(markdown: string): CouncilDecisionRecord | undefined { + const sections = new Map(); + const heading = /^#{1,2}\s+(.+?)\s*$/gm; + const hits = [...markdown.matchAll(heading)]; + for (let i = 0; i < hits.length; i++) { + const name = hits[i][1].trim().toLowerCase(); + const start = (hits[i].index ?? 0) + hits[i][0].length; + const end = hits[i + 1]?.index ?? markdown.length; + sections.set(name, markdown.slice(start, end).trim()); + } + const decision = sections.get('decision'); + if (!decision) return undefined; + const text = (name: string) => sections.get(name)?.trim() ?? ''; + const bullets = (name: string) => text(name).split(/\r?\n/).map((line) => line.replace(/^\s*[-*]\s+/, '').trim()).filter(Boolean); + return { + decision, + whyNow: text('why now'), + evidenceFor: bullets('evidence for'), + evidenceAgainst: bullets('evidence against'), + confidenceBand: text('confidence band'), + invalidation: bullets('invalidation'), + owner: text('owner'), + nextReview: text('next review'), + }; +} export interface PatternDef { icon: string; @@ -77,6 +104,16 @@ export const MEETING_PATTERNS: Record = { output: (_slug, pr) => `reviews/pr-${pr ?? 'n'}.md`, needs: 'pr', }, + council: { + icon: 'πŸ›οΈ', + label: 'LokiDewa council', + blurb: 'Mandate-scoped councillors advise from their own remit β€” whale flow, hunter alpha, risk, execution β€” and the head of the table writes the decision LokiDewa acts on.', + roles: ['Council chair', 'Whale watcher', 'Alpha hunter', 'Risk warden', 'Builder'], + seats: { min: 2, max: 5, default: 4 }, + rounds: { min: 2, max: 4, default: 3 }, + roundsNote: 'Briefings from each mandate, a round of challenge per extra round, then the decision.', + output: (slug) => `docs/decisions/${slug}.md`, + }, }; export const MEETING_PATTERN_IDS = Object.keys(MEETING_PATTERNS) as MeetingPattern[]; @@ -162,5 +199,21 @@ export function meetingSummary(m: Meeting): string { } export function meetingRecord(m: Meeting): MeetingRecord { - return { id: m.id, pattern: m.pattern, title: m.title, status: m.status, summary: meetingSummary(m), calledBy: m.calledBy, finishedAt: m.finishedAt ?? Date.now(), branch: m.worktree?.branch, output: m.output }; + return { + id: m.id, + pattern: m.pattern, + title: m.title, + prompt: m.prompt, + status: m.status, + summary: meetingSummary(m), + calledBy: m.calledBy, + startedAt: m.startedAt, + finishedAt: m.finishedAt ?? Date.now(), + rounds: m.round, + seats: m.seats.map(({ role, workerName, tokens, cost }) => ({ role, workerName, tokens, cost })), + branch: m.worktree?.branch, + output: m.output, + preview: m.preview, + decision: m.pattern === 'council' && m.preview ? parseCouncilDecision(m.preview) : undefined, + }; } diff --git a/src/shared/presence.ts b/src/shared/presence.ts new file mode 100644 index 000000000..27e9eb791 --- /dev/null +++ b/src/shared/presence.ts @@ -0,0 +1,8 @@ +import type { Meeting } from './protocol.js'; + +export function meetingPresence(m: Meeting | null, replayTitle?: string): string { + if (replayTitle) return `βͺ replaying meeting: ${replayTitle}`; + if (!m) return 'πŸ›οΈ preparing a council'; + if (m.status === 'running') return m.pattern === 'council' ? `πŸ›οΈ observing council: ${m.title}` : `🀝 observing meeting: ${m.title}`; + return m.pattern === 'council' ? `πŸ“œ reading council decision: ${m.title}` : `πŸ“„ reading meeting result: ${m.title}`; +} diff --git a/src/shared/prompts.ts b/src/shared/prompts.ts index e6a09af93..23843464a 100644 --- a/src/shared/prompts.ts +++ b/src/shared/prompts.ts @@ -357,6 +357,38 @@ const DEFS = { needs: ['output'], text: `Read every reviewer's findings ({{findings}}). Drop the duplicates, keeping the clearest wording, and write one combined review to {{output}} in Markdown: a short summary with your verdict first, then the findings, the most serious first, each tagged with the lens it came from in bold brackets like **[{{exampleRole}}]**, with its file:line. Don't post it: the office posts it on the pull request once the file is written. ${OUTPUT}`, }, + 'meeting.council.brief': { + group: 'meetings', + label: 'LokiDewa council Β· brief', + used: 'Round 1 of a LokiDewa council, for each councillor.', + vars: { role: 'Their mandate: Whale watcher, Alpha hunter, Risk warden, Builder', question: 'The question LokiDewa put to the council', file: FILE_NOTE }, + needs: ['file'], + text: `The council is meeting on: {{question}}\n\nYou are the {{role}}. Answer only from your own mandate β€” the remit your role names β€” and say nothing about what lies outside it. Where a question falls outside your mandate, say so plainly and pass: a council that stays in its lanes decides faster.\n\nWrite your briefing to {{file}}: what your mandate sees in this, with the evidence behind it, and one thing you'd advise the chair to do. Be concrete; name names, numbers and files where you have them. Then end your turn.`, + }, + 'meeting.council.challenge': { + group: 'meetings', + label: 'LokiDewa council Β· challenge', + used: 'A middle round of a LokiDewa council, for each councillor.', + vars: { role: 'Their mandate', otherBriefings: "The other councillors' latest notes", file: FILE_NOTE }, + needs: ['file'], + text: `Read the other councillors' briefings ({{otherBriefings}}). As the {{role}}, challenge what doesn't hold up in your mandate's own terms: a claim with nothing behind it, a risk nobody has priced, a step that costs more than it pays. Say where you were wrong in your own last briefing too β€” a councillor that revises earns the chair's ear.\n\nWrite your challenge to {{file}}, and revise your own advice from the last round in it. Then end your turn.`, + }, + 'meeting.council.weigh': { + group: 'meetings', + label: 'LokiDewa council Β· weigh', + used: 'A middle round of a LokiDewa council, for the chair.', + vars: { role: "The chair's role", briefings: "The councillors' latest notes", challenges: 'The same, to weigh against each other', file: FILE_NOTE }, + needs: ['file'], + text: `Weigh the councillors' briefings ({{briefings}}) against each other and against the challenges they raised ({{challenges}}). As the {{role}}, name where they agree, where they're at odds and which disagreement matters most to the question.\n\nWrite your weighing to {{file}}: the one decision that survives the challenges, what it would take to change your mind, and what the council should settle in its last round. Then end your turn.`, + }, + 'meeting.council.decide': { + group: 'meetings', + label: 'LokiDewa council Β· decide', + used: 'The last round of a LokiDewa council, for the chair. This file is what LokiDewa acts on.', + vars: { title: 'The meeting’s title', briefings: "Every councillor's notes", notes: 'The whole meeting’s notes', output: OUTPUT_NOTE }, + needs: ['output'], + text: `The council has met on: {{title}}\n\nRead every councillor's notes ({{briefings}}) and the rounds between them ({{notes}}). Write the evidence-first decision to {{output}} using exactly these Markdown headings:\n\n# Decision\n## Why Now\n## Evidence For\n## Evidence Against\n## Confidence Band\n## Invalidation\n## Owner\n## Next Review\n\nUse bullets under both evidence sections and Invalidation. Confidence Band is a plain Low/Medium/High evidence band, never a probability. State what LokiDewa does, who owns it, what proves it wrong, and when it must be reviewed. This file is what LokiDewa acts on β€” write it as the decision, not a recommendation. ${OUTPUT}`, + }, // --- 🏷️ Worker signs --- 'office.namer': { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ee0d258ef..3a5673d39 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -5,6 +5,7 @@ // unions every frame is one of. import type { AccountsClientMsg, AccountsServerMsg, SignInsClientMsg, TeamClientMsg } from './protocol/accounts.js'; +import type { AlertServerMsg } from './protocol/alerts.js'; import type { ChangesClientMsg, ChangesServerMsg } from './protocol/changes.js'; import type { FloorClientMsg, FloorServerMsg, PlanClientMsg } from './protocol/floors.js'; import type { GitHubClientMsg, GitHubServerMsg } from './protocol/github.js'; @@ -18,6 +19,7 @@ import type { UsageClientMsg, UsageServerMsg } from './protocol/usage.js'; import type { WorkerClientMsg, WorkerServerMsg } from './protocol/workers.js'; export * from './protocol/accounts.js'; +export * from './protocol/alerts.js'; export * from './protocol/agents.js'; export * from './protocol/changes.js'; export * from './protocol/floors.js'; @@ -66,4 +68,5 @@ export type ServerMsg = | AccountsServerMsg | SettingsServerMsg | UsageServerMsg + | AlertServerMsg | ToysServerMsg; diff --git a/src/shared/protocol/alerts.ts b/src/shared/protocol/alerts.ts new file mode 100644 index 000000000..2986b3bbd --- /dev/null +++ b/src/shared/protocol/alerts.ts @@ -0,0 +1,42 @@ +export type AlertKind = 'accumulation' | 'convergence' | 'insider' | 'entry-watch'; +export type AlertTopic = 'general' | 'convergence' | 'insider' | 'entry-watch'; +export type AlertSeverity = 'info' | 'watch' | 'urgent'; + +export interface AlertInput { + kind: AlertKind; + title: string; + token?: string; + chain?: string; + severity: AlertSeverity; + whyNow: string; + evidence: string[]; + freshness: string; + coverage: string; + invalidation: string; + links?: { label: string; url: string }[]; +} + +export interface AlertCard { + id: string; + kind: AlertKind; + topic: AlertTopic; + title: string; + token?: string; + chain?: string; + severity: AlertSeverity; + createdAt: number; + layers: { + whyNow: string; + evidence: string[]; + freshness: string; + coverage: string; + invalidation: string; + }; + links: { label: string; url: string }[]; +} + +export interface AlertState { + cards: AlertCard[]; +} + +export type AlertServerMsg = { t: 'alerts'; state: AlertState } | { t: 'alert.added'; card: AlertCard }; diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index ce908eaa2..6ed424ef1 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -9,6 +9,7 @@ import type { BallState } from '../hoop.js'; import type { JukeboxState } from '../jukebox.js'; import type { WhiteboardView } from '../whiteboard.js'; import type { AgentProvider } from './agents.js'; +import type { AlertState } from './alerts.js'; import type { GhIssue, GhPull, GhState } from './github.js'; import type { MeetingState } from './meetings.js'; import type { PeerInfo } from './presence.js'; @@ -103,6 +104,8 @@ export interface FloorView { issues: GhState; pulls: GhState; queue: QueueState; + /** LokiDewa alert cards pinned to this floor. */ + alerts: AlertState; /** Pictures on this floor's walls. */ decor: Decoration[]; /** The signs over this floor's desks, and how far its back office is built out. */ diff --git a/src/shared/protocol/meetings.ts b/src/shared/protocol/meetings.ts index ed54d60f3..df335da0d 100644 --- a/src/shared/protocol/meetings.ts +++ b/src/shared/protocol/meetings.ts @@ -3,7 +3,7 @@ import type { AgentEffort, AgentProvider } from './agents.js'; /** How the workers at the meeting table work together (see shared/meetings.ts). */ -export type MeetingPattern = 'debate' | 'lead' | 'mapreduce' | 'redblue' | 'review'; +export type MeetingPattern = 'debate' | 'lead' | 'mapreduce' | 'redblue' | 'review' | 'council'; /** A worker's place at a meeting. */ export interface MeetingSeat { @@ -35,6 +35,18 @@ export interface MeetingTurn { export type MeetingStatus = 'running' | 'done' | 'stopped'; +/** Evidence-first decision written by LokiDewa Council. */ +export interface CouncilDecisionRecord { + decision: string; + whyNow: string; + evidenceFor: string[]; + evidenceAgainst: string[]; + confidenceBand: string; + invalidation: string[]; + owner: string; + nextReview: string; +} + /** * A meeting in the meeting room: 2–5 workers on one question or task, in rounds, following a pattern. * It ends when its output file is written, or stops at its round limit and says why. @@ -100,13 +112,19 @@ export interface MeetingRecord { id: string; pattern: MeetingPattern; title: string; + prompt: string; status: MeetingStatus; /** The line on the room's door: pattern, rounds, tokens, cost, and the output (or why it stopped). */ summary: string; calledBy: string; + startedAt: number; finishedAt: number; + rounds: number; + seats: Array>; branch?: string; output: string; + preview?: string; + decision?: CouncilDecisionRecord; } export interface MeetingState { diff --git a/src/shared/providers.ts b/src/shared/providers.ts index 720dd8725..b53671229 100644 --- a/src/shared/providers.ts +++ b/src/shared/providers.ts @@ -4,7 +4,7 @@ // // Browser-safe: no node imports, the hire dialog reads this too. -export const AGENT_PROVIDERS = ['claude', 'opencode', 'codex', 'grok', 'muse', 'dsh', 'pi', 'cursor', 'custom'] as const; +export const AGENT_PROVIDERS = ['claude', 'opencode', 'codex', 'grok', 'muse', 'dsh', 'pi', 'cursor', 'custom', 'hermes'] as const; export type AgentProvider = (typeof AGENT_PROVIDERS)[number]; @@ -90,6 +90,19 @@ export function isValidMuseModel(value: unknown): value is string { return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value); } +/** + * A Hermes model: a model alias, or a provider/model id (`AtriaAI-Atria/Atria-Dawn-Preview`, + * `anthropic/claude-sonnet-4`). Only the characters a model name uses get through, and never a + * leading '-': it goes to `hermes chat -m` as one argv value. + */ +export const HERMES_MODEL_MAX = 128; + +export function isValidHermesModel(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0 || value.length > HERMES_MODEL_MAX) return false; + if (/[\s\p{Cc}\p{Cf}]/u.test(value)) return false; + return /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(value); +} + /** * Codex model ids are argv values: its own (`gpt-5.5`), or a local provider's (`gpt-oss:20b`, * `openai/gpt-oss-20b`). Only the characters a model name uses get through, and never a leading '-'. @@ -326,6 +339,24 @@ export const PROVIDER_META: Record = { unpicked: 'A custom --agent runs as it is: set its model and effort in --agent-args.', usage: { note: 'Usage is untracked unless compatible Claude Code hooks report it.' }, }, + hermes: { + label: 'Hermes', + name: 'Hermes Agent', + bin: 'hermes', + unpicked: 'A Hermes Agent session (hermes chat). Its session ID is kept by the office, so a model swap re-launches against the same session β€” the briefcase persists, the brain changes.', + validModel: isValidHermesModel, + invalidModel: 'Invalid Hermes model (expected a model alias or provider/model without whitespace)', + models: { + pick: 'typed', + unset: 'Default (Hermes settings)', + max: HERMES_MODEL_MAX, + hint: 'Optional model alias (e.g. AtriaAI-Atria/Atria-Dawn-Preview) or provider/model; leave Default to use your Hermes config.', + invalid: 'Use a Hermes model: a model alias or provider/model β€” letters, digits and . _ : / @ + - (up to 128 characters).', + }, + takesEffort: true, + effortLabel: 'Reasoning', + usage: { note: 'Usage is read from the session transcript, as Claude Code’s is.' }, + }, }; /** What the table says about `provider`, when it's one (saved state and the wire can hold anything). */ diff --git a/tests/access.test.ts b/tests/access.test.ts new file mode 100644 index 000000000..33c8907a9 --- /dev/null +++ b/tests/access.test.ts @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { councilAccess } from '../src/shared/access.js'; + +test('maps existing account model to friendly council roles and capabilities', () => { + assert.deepEqual(councilAccess({ admin: true, account: { role: 'admin' } }), { role: 'Owner', manage: true, research: true, execute: true }); + assert.deepEqual(councilAccess({ admin: false, account: { role: 'member' } }), { role: 'Research Partner', manage: false, research: true, execute: true }); + assert.deepEqual(councilAccess({ admin: false }), { role: 'Guest', manage: false, research: false, execute: false }); +}); diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 1223f797d..33eb91c2a 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -92,7 +92,7 @@ test('model validation follows the provider', () => { assert.match(validateWorkerModel('agent', 'codex', '--yolo') ?? '', /Invalid Codex model/); assert.match(validateWorkerModel('agent', 'codex', 'gpt 5.5') ?? '', /Invalid Codex model/); // Custom still takes none. - assert.match(validateWorkerModel('agent', 'custom', 'anything') ?? '', /Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor/); + assert.match(validateWorkerModel('agent', 'custom', 'anything') ?? '', /claude|opencode|codex|grok|muse|dsh|pi|cursor|hermes/i); }); test('reasoning effort joins Claude for DeepSeek Harness', () => { @@ -104,7 +104,7 @@ test('reasoning effort joins Claude for DeepSeek Harness', () => { assert.equal(validateWorkerEffort('agent', 'codex', 'high'), undefined); assert.equal(validateWorkerEffort('agent', 'opencode', 'high'), undefined); assert.match(validateWorkerEffort('agent', 'opencode', 'enormous') ?? '', /Invalid effort/); - assert.match(validateWorkerEffort('agent', 'custom', 'high') ?? '', /Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness or Pi/); + assert.match(validateWorkerEffort('agent', 'custom', 'high') ?? '', /Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Hermes Agent/); }); test('every provider the office knows the CLI of takes a model and (but for Cursor) an effort, with the fields to pick them', () => { diff --git a/tests/alerts-ui.test.ts b/tests/alerts-ui.test.ts new file mode 100644 index 000000000..ecb3b2e34 --- /dev/null +++ b/tests/alerts-ui.test.ts @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { AlertCard } from '../src/shared/protocol/alerts.js'; +import { filterAlerts } from '../src/client/ui/alerts.js'; + +const card = (topic: AlertCard['topic'], title: string): AlertCard => ({ + id: title, + kind: topic === 'general' ? 'accumulation' : topic, + topic, + title, + severity: 'watch', + createdAt: 1, + layers: { whyNow: 'why', evidence: [], freshness: 'now', coverage: 'GMGN', invalidation: 'none' }, + links: [], +}); + +const cards = [card('general', 'A'), card('convergence', 'B'), card('insider', 'C')]; + +test('filterAlerts returns all cards or one LokiDewa topic', () => { + assert.deepEqual(filterAlerts(cards, 'all').map((c) => c.title), ['A', 'B', 'C']); + assert.deepEqual(filterAlerts(cards, 'convergence').map((c) => c.title), ['B']); +}); diff --git a/tests/alerts.test.ts b/tests/alerts.test.ts new file mode 100644 index 000000000..f835b8997 --- /dev/null +++ b/tests/alerts.test.ts @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { AlertBoard, topicForAlert, type AlertInput } from '../src/server/alerts.js'; + +const input: AlertInput = { + title: 'WLD whale accumulation', + token: 'WLD', + chain: 'ethereum', + severity: 'info', + whyNow: 'Fresh whale buy + GMGN convergence', + evidence: ['wallet 0xabc bought 120k WLD', 'GMGN trader score confirmed'], + freshness: 'seen 2m ago', + coverage: 'Arkham + GMGN', + invalidation: 'transfer-only, no fresh balance increase', + links: [{ label: 'gmgn', url: 'https://gmgn.ai/eth/token/0xabc' }], +}; + +test('topicForAlert maps LokiDewa alert cards to Telegram-style topics', () => { + assert.equal(topicForAlert({ ...input, kind: 'accumulation' }), 'general'); + assert.equal(topicForAlert({ ...input, kind: 'convergence' }), 'convergence'); + assert.equal(topicForAlert({ ...input, kind: 'insider' }), 'insider'); + assert.equal(topicForAlert({ ...input, kind: 'entry-watch' }), 'entry-watch'); +}); + +test('AlertBoard keeps newest WHY NOW cards first and trims old cards', () => { + const board = new AlertBoard(2); + const first = board.add({ ...input, kind: 'accumulation', title: 'first' }, 1000); + const second = board.add({ ...input, kind: 'convergence', title: 'second' }, 2000); + const third = board.add({ ...input, kind: 'entry-watch', title: 'third' }, 3000); + + assert.equal(first.topic, 'general'); + assert.equal(second.topic, 'convergence'); + assert.deepEqual(board.state().cards.map((c) => c.title), ['third', 'second']); + assert.equal(third.layers.whyNow, input.whyNow); + assert.deepEqual(third.layers.evidence, input.evidence); +}); diff --git a/tests/client-store.test.ts b/tests/client-store.test.ts index adf83062c..e61ba847a 100644 --- a/tests/client-store.test.ts +++ b/tests/client-store.test.ts @@ -225,7 +225,7 @@ test('what the browser remembers keeps its keys and shapes', () => { test("the store's keys are its state, as window.__office shows them", () => { // As the office had them before its store was split into slices: methods and the slices aren't among them. - assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); + assert.deepEqual(Object.keys(store).sort(), ['accounts', 'alerts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); }); test('a new store starts every field where it always has', async () => { @@ -239,7 +239,7 @@ test('a new store starts every field where it always has', async () => { JSON.parse(JSON.stringify(rest, (_k, v) => (v instanceof Map ? [...v] : v === undefined ? '' : v))), { you: '', peers: [], workers: [], screens: [], project: null, floors: [], floor: null, projectsDir: { dir: '', custom: false }, - repos: { list: [], loading: false, at: 0 }, issues: { items: [], fetchedAt: 0, loading: true }, pulls: { items: [], fetchedAt: 0, loading: true }, + repos: { list: [], loading: false, at: 0 }, alerts: { cards: [] }, issues: { items: [], fetchedAt: 0, loading: true }, pulls: { items: [], fetchedAt: 0, loading: true }, ice: [], chat: [], invites: false, queue: { tasks: [], maxWorkers: 0 }, me: { admin: false }, upgrade: { available: false, phase: 'idle' }, usage: { total: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, calls: 0 }, today: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, calls: 0 }, day: '', pauseHiring: false }, diff --git a/tests/council-decision.test.ts b/tests/council-decision.test.ts new file mode 100644 index 000000000..0e3412fd8 --- /dev/null +++ b/tests/council-decision.test.ts @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCouncilDecision } from '../src/shared/meetings.js'; + +test('parses evidence-first council decision sections', () => { + const parsed = parseCouncilDecision(`# Decision\nAccumulate only after confirmation.\n## Why Now\nFresh wallet convergence.\n## Evidence For\n- Wallet A bought\n- Balance increased\n## Evidence Against\n- Thin liquidity\n## Confidence Band\nMedium\n## Invalidation\n- Wallet distributes\n## Owner\nWhale watcher\n## Next Review\n4 hours`)!; + assert.equal(parsed.decision, 'Accumulate only after confirmation.'); + assert.equal(parsed.whyNow, 'Fresh wallet convergence.'); + assert.deepEqual(parsed.evidenceFor, ['Wallet A bought', 'Balance increased']); + assert.deepEqual(parsed.evidenceAgainst, ['Thin liquidity']); + assert.equal(parsed.confidenceBand, 'Medium'); + assert.deepEqual(parsed.invalidation, ['Wallet distributes']); + assert.equal(parsed.owner, 'Whale watcher'); + assert.equal(parsed.nextReview, '4 hours'); +}); + +test('does not invent a structured record without a Decision heading', () => { + assert.equal(parseCouncilDecision('ordinary meeting notes'), undefined); +}); diff --git a/tests/council-parts.test.ts b/tests/council-parts.test.ts new file mode 100644 index 000000000..7896d2abc --- /dev/null +++ b/tests/council-parts.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { councilParts } from '../src/server/council-parts.js'; +import type { Meeting } from '../src/shared/protocol.js'; + +const meeting = { + title: 'Alpha review', prompt: 'Review WLD', output: 'decision.md', notes: 'notes', rounds: 3, + seats: [ + { role: 'Council chair', deskId: 'a' }, + { role: 'Whale watcher', deskId: 'b' }, + { role: 'Risk warden', deskId: 'c' }, + ], +} as Meeting; +const say = (id: string) => id; + +test('council briefs, challenges, then chair writes final decision', () => { + const first = councilParts(meeting, 1, 1, '/tmp/council', 'notes', say as never)!; + assert.deepEqual(first.map((p) => p.seat), [1, 2]); + assert.ok(first.every((p) => p.doing === 'briefing their mandate')); + + const middle = councilParts(meeting, 2, 1, '/tmp/council', 'notes', say as never)!; + assert.deepEqual(middle.map((p) => p.seat), [0, 1, 2]); + assert.ok(middle.every((p) => p.file !== meeting.output)); + + const last = councilParts(meeting, 3, 1, '/tmp/council', 'notes', say as never)!; + assert.deepEqual(last.map((p) => [p.seat, p.file]), [[0, meeting.output]]); +}); diff --git a/tests/lobby.test.ts b/tests/lobby.test.ts new file mode 100644 index 000000000..8efe5ae29 --- /dev/null +++ b/tests/lobby.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { dailyBrief, integrationStatus } from '../src/client/ui/lobby.js'; + +const state = { + workers: [{ status: 'working' }, { status: 'needs_input' }, { status: 'done' }], + queue: [{ status: 'queued' }, { status: 'running' }, { status: 'done' }], + alerts: [{ severity: 'urgent' }, { severity: 'watch' }], + issues: [{ state: 'OPEN' }, { state: 'CLOSED' }], + pulls: [{ state: 'OPEN' }], + council: { status: 'running' }, +}; + +test('daily brief summarizes actionable LokiDewa work', () => { + assert.deepEqual(dailyBrief(state), { + activeWorkers: 2, + waitingWorkers: 1, + openTasks: 2, + alerts: 2, + urgentAlerts: 1, + openIssues: 1, + openPulls: 1, + councilActive: true, + }); +}); + +test('integration status never labels configured-only capabilities live', () => { + const status = integrationStatus({ + floor: 'dewa', + project: { agentProviders: ['hermes', 'muse'] }, + workers: [ + { provider: 'hermes', model: 'OLLAMA/qwen3.5:9b', status: 'working' }, + { provider: 'muse', status: 'done' }, + ], + }); + assert.deepEqual(status, { + server: 'LIVE', + research: 'OFFLINE', + qwen: 'LIVE', + muse: 'CONFIGURED', + remotion: 'PLANNED', + }); +}); + +test('research is live only when its own cards are on the board', () => { + const base = { floor: 'dewa', project: { agentProviders: [] }, workers: [] }; + assert.equal(integrationStatus({ ...base, alerts: [] }).research, 'OFFLINE'); + assert.equal(integrationStatus({ ...base, alerts: [{ kind: 'convergence' }] }).research, 'OFFLINE'); + assert.equal(integrationStatus({ ...base, alerts: [{ kind: 'insider' }] }).research, 'LIVE'); +}); + +test('nothing is reported live without real evidence', () => { + const status = integrationStatus({ floor: null, project: null, workers: [], alerts: [] }); + assert.deepEqual(status, { + server: 'OFFLINE', + research: 'OFFLINE', + qwen: 'OFFLINE', + muse: 'OFFLINE', + remotion: 'PLANNED', + }); + assert.ok(!Object.values(status).includes('LIVE')); +}); diff --git a/tests/meeting-replay.test.ts b/tests/meeting-replay.test.ts new file mode 100644 index 000000000..f08eb8f09 --- /dev/null +++ b/tests/meeting-replay.test.ts @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { meetingRecord } from '../src/shared/meetings.js'; +import type { Meeting } from '../src/shared/protocol.js'; + +test('meeting archive retains enough data for council replay', () => { + const meeting = { + id: 'c1', pattern: 'council', title: 'WLD review', prompt: 'Review WLD now', output: 'decision.md', + seats: [{ role: 'Council chair', deskId: 'a', workerName: 'Chair' }], rounds: 3, round: 3, step: 1, + turns: [], tokens: 10, cost: 0, costKnown: false, status: 'done', calledBy: 'LO', startedAt: 100, + finishedAt: 200, notes: '.meeting', preview: '# Decision\nWait.\n## Why Now\nFlow changed.\n## Evidence For\n- A\n## Evidence Against\n- B\n## Confidence Band\nMedium\n## Invalidation\n- C\n## Owner\nWhale watcher\n## Next Review\n4h', + } as Meeting; + const record = meetingRecord(meeting); + assert.equal(record.prompt, meeting.prompt); + assert.equal(record.startedAt, 100); + assert.equal(record.rounds, 3); + assert.equal(record.seats[0].role, 'Council chair'); + assert.equal(record.preview, meeting.preview); + assert.equal(record.decision?.owner, 'Whale watcher'); +}); diff --git a/tests/presence.test.ts b/tests/presence.test.ts new file mode 100644 index 000000000..05aa13450 --- /dev/null +++ b/tests/presence.test.ts @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { meetingPresence } from '../src/shared/presence.js'; +import type { Meeting } from '../src/shared/protocol.js'; + +const meeting = (status: Meeting['status'], pattern: Meeting['pattern'] = 'council') => ({ title: 'WLD review', status, pattern }) as Meeting; + +test('meeting presence describes council, decision reading, and replay', () => { + assert.equal(meetingPresence(meeting('running')), 'πŸ›οΈ observing council: WLD review'); + assert.equal(meetingPresence(meeting('done')), 'πŸ“œ reading council decision: WLD review'); + assert.equal(meetingPresence(null, 'Old meeting'), 'βͺ replaying meeting: Old meeting'); + assert.equal(meetingPresence(null), 'πŸ›οΈ preparing a council'); +}); diff --git a/tests/research-alerts.test.ts b/tests/research-alerts.test.ts new file mode 100644 index 000000000..1258578ba --- /dev/null +++ b/tests/research-alerts.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { AlertBoard } from '../src/server/alerts.js'; +import { loadResearchAlerts } from '../src/server/research-feed.js'; + +const REPORT = [ + 'LAPORAN RISET WHALE β€” 2026-10-12 01:19 UTC', + 'shortlist 09623f420004ff34 Β· protokol v1 1f71d9ce9db21ad5', + 'A. Entitas shortlist (protokol v1)', + 'β€’ 2en1JiW9d8er: n=1 (1 token), median net -9.9% [biaya 0/3/6%: -6.9% / -9.9% / -12.9%], menang β‰₯0% (Wilson, n=1) β€” INSUFFICIENT', +].join('\n'); + +test('loadResearchAlerts reads a report file and fills the board read-only', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'dewa-research-')); + const file = path.join(dir, 'report.txt'); + writeFileSync(file, REPORT); + const board = new AlertBoard(); + const added = loadResearchAlerts(board, file); + assert.equal(added, 1); + const state = board.state(); + assert.equal(state.cards.length, 1); + assert.equal(state.cards[0].kind, 'insider'); + assert.match(state.cards[0].layers.invalidation, /INSUFFICIENT/); +}); + +test('loadResearchAlerts leaves the board empty when the report is missing', () => { + const board = new AlertBoard(); + const added = loadResearchAlerts(board, path.join(tmpdir(), 'nope', 'report.txt')); + assert.equal(added, 0); + assert.equal(board.state().cards.length, 0); +}); diff --git a/tests/research-feed.test.ts b/tests/research-feed.test.ts new file mode 100644 index 000000000..daf35d092 --- /dev/null +++ b/tests/research-feed.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { researchCards } from '../src/server/research-feed.js'; + +const REPORT = [ + 'LAPORAN RISET WHALE β€” 2026-10-12 01:19 UTC', + 'shortlist 09623f420004ff34 Β· protokol v1 1f71d9ce9db21ad5', + 'A. Entitas shortlist (protokol v1)', + 'Baseline (wallet lain): menang 50% [Wilson 9%–91%], n=2', + 'β€’ 2en1JiW9d8er: n=1 (1 token), median net -9.9% [biaya 0/3/6%: -6.9% / -9.9% / -12.9%], menang β‰₯0% (Wilson, n=1) β€” INSUFFICIENT', + 'β€’ 7wePDgzTECQQ: n=6 (6 token), median net -28.9%, menang β‰₯0% (Wilson, n=6) β€” INSUFFICIENT', + '', + 'B. Hipotesis konteks masuk (protokol v2)', + 'β€’ H1 konvergensi β‰₯2 entitas: INSUFFICIENT (ya n=0/0 token, tidak n=1/1 token)', + '', + 'C. Return horizon tetap (protokol v3)', + 'Horizon 1h sejak live: OBSERVED 0 (shortlist 0, baseline 0), INSUFFICIENT_SAMPLES 0 tidak dihitung.', +].join('\n'); + +test('research feed turns shortlist entity lines into WHY NOW cards', () => { + const cards = researchCards(REPORT); + assert.equal(cards.length, 2); + const first = cards[0]; + assert.equal(first.kind, 'insider'); + assert.equal(first.severity, 'info'); + assert.match(first.title, /2en1JiW9d8er/); + assert.match(first.whyNow, /median net -9\.9%/); + assert.match(first.coverage, /n=1/); + assert.match(first.invalidation, /INSUFFICIENT/); + assert.equal(first.freshness.slice(0, 10), '2026-10-12'); + assert.deepEqual(first.links, []); +}); + +test('research feed never claims SUPPORTED or probability', () => { + const synthetic = ['LAPORAN RISET WHALE β€” 2026-10-12 01:19 UTC', + 'β€’ bestwallet: n=40, SUPPORTED β€” follower menang minimal 90%'].join('\n'); + assert.deepEqual(researchCards(synthetic), []); +}); + +test('research feed reports horizon coverage honestly', () => { + const horizon = ['LAPORAN RISET WHALE β€” 2026-10-12 01:19 UTC', + 'Horizon 1h sejak live: OBSERVED 0 (shortlist 0, baseline 0), INSUFFICIENT_SAMPLES 0 tidak dihitung.'].join('\n'); + const cards = researchCards(horizon); + assert.equal(cards.length, 0); +}); diff --git a/tests/research-wiring.test.ts b/tests/research-wiring.test.ts new file mode 100644 index 000000000..d4142ea53 --- /dev/null +++ b/tests/research-wiring.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { researchCards } from '../src/server/research-feed.js'; + +const REPORT = [ + 'LAPORAN RISET WHALE β€” 2026-10-13 02:00 UTC', + 'shortlist 09623f420004ff34 Β· protokol v1 abc123', + 'A. Entitas shortlist (protokol v1)', + 'β€’ TESTWALLET1: n=3 (3 token), median net +12.5% β€” INSUFFICIENT', + 'B. Hipotesis konteks masuk (protokol v2)', + 'β€’ H1 konvergensi: INSUFFICIENT (ya n=0, tidak n=1)', +].join('\n'); + +test('researchCards extracts wallet, verdict, and freshness from a valid report', () => { + const cards = researchCards(REPORT); + assert.equal(cards.length, 1); + assert.equal(cards[0].kind, 'insider'); + assert.match(cards[0].title, /TESTWALLET1/); + assert.match(cards[0].whyNow, /median net/); + assert.equal(cards[0].freshness, '2026-10-13'); +}); + +test('researchCards returns empty array when report has SUPPORTED claim', () => { + const bad = REPORT + '\nβ€’ fakewallet: n=50, SUPPORTED β€” follower menang 95%'; + assert.deepEqual(researchCards(bad), []); +}); + +test('researchCards returns empty array for malformed header', () => { + assert.deepEqual(researchCards('not a whale report'), []); +}); diff --git a/tests/server-dispatch.test.ts b/tests/server-dispatch.test.ts index 2331c41fc..758144dfb 100644 --- a/tests/server-dispatch.test.ts +++ b/tests/server-dispatch.test.ts @@ -267,7 +267,7 @@ test('welcomes a browser and dispatches what it sends', async () => { assert.equal(ada?.name, 'Ada'); assert.equal(ada?.color, '#ff8a5b'); assert.equal(ada?.floor, floor.id); - assert.deepEqual(Object.keys(welcome).slice(-17), ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'decor', 'plan', 'services', 'dog', 'ball', 'cars', 'jail', 'jukebox', 'whiteboard', 'meeting', 'cabinet']); + assert.deepEqual(Object.keys(welcome).slice(-18), ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'alerts', 'decor', 'plan', 'services', 'dog', 'ball', 'cars', 'jail', 'jukebox', 'whiteboard', 'meeting', 'cabinet']); a.send({ t: 'ping', at: 42 }); const pong = await a.take('pong'); diff --git a/tests/size.test.ts b/tests/size.test.ts index be8ce888f..aabe2e1da 100644 --- a/tests/size.test.ts +++ b/tests/size.test.ts @@ -24,7 +24,7 @@ const CEILINGS: Readonly> = { 'src/server/workers/manager.ts': 1029, 'src/client/features/rooftop/world.ts': 989, 'src/client/world/sky.ts': 966, - 'src/server/meetings.ts': 764, + 'src/server/meetings.ts': 768, 'src/client/world/holiday.ts': 702, 'src/client/features/dog/world.ts': 702, 'src/client/world/character/person.ts': 694, From d1d248cf9a03e74e0133ae8583ee407fb6fa78d2 Mon Sep 17 00:00:00 2001 From: nindya zahra kusuma Date: Wed, 14 Oct 2026 00:24:22 +0700 Subject: [PATCH 2/2] [verified] Fix council replay round limit --- src/shared/meetings.ts | 2 +- tests/meeting-replay.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/meetings.ts b/src/shared/meetings.ts index a4d6ff801..e6e270d40 100644 --- a/src/shared/meetings.ts +++ b/src/shared/meetings.ts @@ -209,7 +209,7 @@ export function meetingRecord(m: Meeting): MeetingRecord { calledBy: m.calledBy, startedAt: m.startedAt, finishedAt: m.finishedAt ?? Date.now(), - rounds: m.round, + rounds: m.rounds, seats: m.seats.map(({ role, workerName, tokens, cost }) => ({ role, workerName, tokens, cost })), branch: m.worktree?.branch, output: m.output, diff --git a/tests/meeting-replay.test.ts b/tests/meeting-replay.test.ts index f08eb8f09..f5d0b48f0 100644 --- a/tests/meeting-replay.test.ts +++ b/tests/meeting-replay.test.ts @@ -6,14 +6,14 @@ import type { Meeting } from '../src/shared/protocol.js'; test('meeting archive retains enough data for council replay', () => { const meeting = { id: 'c1', pattern: 'council', title: 'WLD review', prompt: 'Review WLD now', output: 'decision.md', - seats: [{ role: 'Council chair', deskId: 'a', workerName: 'Chair' }], rounds: 3, round: 3, step: 1, + seats: [{ role: 'Council chair', deskId: 'a', workerName: 'Chair' }], rounds: 4, round: 3, step: 1, turns: [], tokens: 10, cost: 0, costKnown: false, status: 'done', calledBy: 'LO', startedAt: 100, finishedAt: 200, notes: '.meeting', preview: '# Decision\nWait.\n## Why Now\nFlow changed.\n## Evidence For\n- A\n## Evidence Against\n- B\n## Confidence Band\nMedium\n## Invalidation\n- C\n## Owner\nWhale watcher\n## Next Review\n4h', } as Meeting; const record = meetingRecord(meeting); assert.equal(record.prompt, meeting.prompt); assert.equal(record.startedAt, 100); - assert.equal(record.rounds, 3); + assert.equal(record.rounds, 4); assert.equal(record.seats[0].role, 'Council chair'); assert.equal(record.preview, meeting.preview); assert.equal(record.decision?.owner, 'Whale watcher');