From eb4a1d6657d550f3195fad0468fc2627f39b39da Mon Sep 17 00:00:00 2001 From: Johannes Bauer Date: Sun, 11 Oct 2026 21:19:20 +0200 Subject: [PATCH] Add human decision pool and Boss Office monitor --- README.md | 1 + bin/office-decisions.js | 16 +++++ docs/code-layout.md | 2 + docs/decisions.md | 38 ++++++++++ src/client/features/decisions/editor.ts | 26 +++++++ src/client/features/decisions/index.ts | 14 ++++ src/client/features/decisions/state.ts | 20 ++++++ src/client/features/decisions/ui.css | 22 ++++++ src/client/features/decisions/ui.ts | 72 +++++++++++++++++++ src/client/features/decisions/world.ts | 35 ++++++++++ src/client/features/hud/index.ts | 2 + src/client/main.ts | 2 + src/client/world/office/boss-monitor.ts | 11 +++ src/client/world/office/build.ts | 2 + src/client/world/office/loft.ts | 9 ++- src/server/decisions/context.ts | 4 ++ src/server/decisions/store.ts | 73 +++++++++++++++++++ src/server/hooks/office-decisions.ts | 20 ++++++ src/server/hooks/routes.ts | 8 +++ src/server/hooks/server.ts | 7 +- src/server/http/routes/decisions.ts | 54 ++++++++++++++ src/server/http/routes/index.ts | 2 + src/server/workers/manager.ts | 10 +-- src/server/workers/process.ts | 2 +- src/server/workers/prompt-extensions.ts | 8 +++ src/shared/decision-prompts.ts | 6 ++ src/shared/decisions.ts | 28 ++++++++ tests/decisions.test.ts | 93 +++++++++++++++++++++++++ tests/pi.test.ts | 4 +- 29 files changed, 575 insertions(+), 16 deletions(-) create mode 100644 bin/office-decisions.js create mode 100644 docs/decisions.md create mode 100644 src/client/features/decisions/editor.ts create mode 100644 src/client/features/decisions/index.ts create mode 100644 src/client/features/decisions/state.ts create mode 100644 src/client/features/decisions/ui.css create mode 100644 src/client/features/decisions/ui.ts create mode 100644 src/client/features/decisions/world.ts create mode 100644 src/client/world/office/boss-monitor.ts create mode 100644 src/server/decisions/context.ts create mode 100644 src/server/decisions/store.ts create mode 100644 src/server/hooks/office-decisions.ts create mode 100644 src/server/hooks/routes.ts create mode 100644 src/server/http/routes/decisions.ts create mode 100644 src/server/workers/prompt-extensions.ts create mode 100644 src/shared/decision-prompts.ts create mode 100644 src/shared/decisions.ts create mode 100644 tests/decisions.test.ts diff --git a/README.md b/README.md index fac9441a5..602ff5b6f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. +- **Decisions for the project owner.** The left monitor on the Boss Office desk opens a [decision pool](docs/decisions.md); Minesweeper stays on the right. Workers submit questions with issue links, categories and tags. Real work blockers light up the monitor and appear first. The Issue Agent scans for decisions alongside human playtests. Saving an answer never adds work to the queue. - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). diff --git a/bin/office-decisions.js b/bin/office-decisions.js new file mode 100644 index 000000000..eaadfd250 --- /dev/null +++ b/bin/office-decisions.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { pathToFileURL } from 'node:url'; +export async function main(args = process.argv.slice(2), env = process.env) { + const command = args[0]; + if (!command || command === '--help') { console.log('office-decisions list | add (JSON on stdin). Human answers are read-only for workers.'); return; } + if (!['list', 'add'].includes(command)) throw Error('Use list or add. Only a human can answer in the Office.'); + if (!env.AGENT_OFFICE_HOOK_URL || !env.AGENT_OFFICE_WORKER_ID || !env.AGENT_OFFICE_HOOK_TOKEN) throw Error('Run inside an Office worker.'); + const url = new URL('/office/decisions', env.AGENT_OFFICE_HOOK_URL); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.protocol !== 'http:') throw Error('Expected local Office hook server.'); + url.searchParams.set('worker', env.AGENT_OFFICE_WORKER_ID); + let body; + if (command === 'add') { let text = ''; for await (const chunk of process.stdin) { text += chunk; if (Buffer.byteLength(text) > 24000) throw Error('Question too long.'); } body = JSON.stringify(JSON.parse(text)); } + const res = await fetch(url, { method: body ? 'POST' : 'GET', body, headers: { authorization: 'Bearer ' + env.AGENT_OFFICE_HOOK_TOKEN, 'content-type': 'application/json' }, signal: AbortSignal.timeout(15000) }); + const result = await res.json(); if (!res.ok) throw Error(result.error || 'Office request failed'); console.log(JSON.stringify(result, null, 2)); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(e => { console.error(e.message); process.exitCode = 1; }); diff --git a/docs/code-layout.md b/docs/code-layout.md index d76e3d42a..b5306ec9d 100644 --- a/docs/code-layout.md +++ b/docs/code-layout.md @@ -87,6 +87,8 @@ One adapter file in `src/server/providers/`, one entry in `PROVIDERS` in `src/se ## The size guard +Human decisions live in `src/client/features/decisions`, `src/server/decisions` and `src/shared/decisions.ts`. The world fixture adds the left Boss Office monitor, while the HUD action opens the same modal. `src/server/hooks/routes.ts` registers loopback worker endpoints, and `src/server/workers/prompt-extensions.ts` composes module-owned instructions for all providers. See [Human decisions](decisions.md) for the CLI, permissions and answer workflow. + `tests/size.test.ts` holds every `.ts` and `.css` file under `src/` to 600 lines. It asks `git ls-files` for them (tracked, and new ones that aren't ignored) rather than walking the folder, so a worktree checked out inside the repo can't trip it. The files that were longer when it came in are listed in `CEILINGS`, each with the length it had then: they may shrink, but never grow past it. Once one is down to 600 lines or fewer, or gone, the test fails until you take it off the list, so the list only gets shorter. When it fails, split the file along the registries: a feature's code goes in its folder, a message handler in its domain's file, a tick or a key in the feature it's for, and what the page keeps of it in its own slice. Raising a ceiling, or adding a file to the list, only hands the problem to the next person, so the honest way to make room is to split the file. When you shrink a listed file, lower its ceiling to its new length in the same change, so it can't grow back. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 000000000..8474d0c97 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,38 @@ +# Human decisions + +The **left monitor on the Boss Office desk** opens **Entscheidungen** (decisions). The existing Minesweeper monitor is on the right. The same list is available from the office menu. Each project has its own persistent pool. + +## Collect and organize + +Use **Issues durchsuchen** to ask the Issue Agent to inspect open and in-progress issues and their comments for unresolved human decisions and manual playtests. Decisions go into this pool. Manual tests stay in the Playtest checklist when its `office-playtests` integration is installed. Scanning does not queue, assign, close or start implementation tasks. + +Workers receive the same instructions when starting, resuming or receiving a task through the Office, including meetings. They use `office-decisions list` and `office-decisions add` to record questions discovered while working. Existing running terminals receive the instructions with their next Office task or resume; the current turn is not interrupted. Raw keystrokes typed directly into a terminal remain unchanged. + +Questions have a category, tags, linked issue numbers, context, alternatives and an optional recommendation. Categories are Spielablauf, Design & UX, Technik, Assets, Release, Organisation and Sonstiges. Search includes question titles, question text, tags and issue numbers. Category and status filters narrow the list. Repeated submissions of the same question and issue set reuse its entry and preserve the human answer. + +Mark **Blockiert Arbeit** only when the linked work cannot continue without an answer, and explain the dependency. These questions appear first and give the monitor a pulsing red alert. Workers must pause the dependent work and preserve it, rather than silently choose an answer or report it complete. Independent work can continue. A pending manual playtest alone is not a decision blocker. + +## Answer and continue + +Open a question, select an alternative or write an answer, then use **Entscheidung speichern**. Only the responsible account or an office administrator can answer. Worker credentials can add questions and read answers, but cannot answer on the human's behalf. + +Saving removes the open blocker alert and stores an answer history. It **does not queue work, assign issues or start workers**. Use **Beantwortet** to see answered questions. If a question came from a worker, **[name] informieren / fortsetzen** explicitly sends the answer to that existing worker. Busy workers and workers waiting for terminal input are left alone; retry after resolving their current activity. Sending the same answer to the same worker twice is prevented. Missing workers leave the answer safely in the pool. + +**Wieder öffnen** reopens a question; editing the question, alternatives or linked issues also reopens it while preserving answer history. The top-right **✕** and **Esc** close each dialog through the standard office modal/focus flow. + +## Worker command + +The office puts `office-decisions` on worker PATH alongside `office-workers`. It uses the existing per-worker authenticated loopback hook, scopes all requests to that worker's project and never accepts a human answer through the hook. + +```sh +office-decisions list +echo '{"title":"Choose release scope","question":"Include the optional tutorial in this release?","context":"Core gameplay is ready; the tutorial requires another pass.","options":["Ship core gameplay first","Wait for the tutorial"],"recommendation":"Ship core gameplay first","category":"Release","tags":["Scope"],"issues":[123],"blocking":true,"blockingReason":"The release scope must be settled before preparing the release."}' | office-decisions add +``` + +Use JSON on standard input (PowerShell supports a single-quoted JSON string piped to the command as well). Responses include stable IDs and revisions. Read the pool again before continuing dependent work; an answer only authorizes the scope it actually addresses. + +## Storage and extension points + +Entries are stored under the office data directory in `decisions/.json`, using atomic file replacement. Preserve this directory when migrating or backing up the office. Answer/edit requests require the latest revision; a stale tab cannot overwrite another person's update. + +This feature owns its model, persistence, authenticated HTTP route, worker hook, launch prompt extension, client state, UI and world fixture. It plugs into the HTTP and hook route registries, the worker prompt extension registry, the office fixture plan and the HUD action registry. It does not modify queue state or dispatch policy. diff --git a/src/client/features/decisions/editor.ts b/src/client/features/decisions/editor.ts new file mode 100644 index 000000000..6c5853fe0 --- /dev/null +++ b/src/client/features/decisions/editor.ts @@ -0,0 +1,26 @@ +import { DECISION_CATEGORIES, type DecisionInput } from '../../../shared/decisions'; +import { h, openModal } from '../../ui/dom'; +export function editDecision(value: Partial, save: (input: DecisionInput) => Promise) { + const title = h('input', { value: value.title ?? '', required: true, maxlength: 180 }); + const question = h('textarea', { required: true, maxlength: 4000, rows: 3 }, value.question ?? ''); + const context = h('textarea', { maxlength: 6000, rows: 3 }, value.context ?? ''); + const options = h('textarea', { rows: 3 }, value.options?.join('\n') ?? ''); + const recommendation = h('textarea', { maxlength: 1000 }, value.recommendation ?? ''); + const category = h('select', {}, ...DECISION_CATEGORIES.map(c => h('option', { value: c, selected: c === (value.category ?? 'Sonstiges') }, c))); + const tags = h('input', { value: value.tags?.join(', ') ?? '', placeholder: 'RIFT, Waffen, Koop' }); + const issues = h('input', { value: value.issues?.join(', ') ?? '', placeholder: '90, 180' }); + const blocking = h('input', { type: 'checkbox', checked: value.blocking ?? false }); + const reason = h('textarea', { maxlength: 1000 }, value.blockingReason ?? ''); + const error = h('p.decision-error', { role: 'alert' }); + const button = h('button.btn.primary', { type: 'submit' }, 'Speichern'); + const field = (name: string, element: HTMLElement) => h('label.decision-field', {}, h('span', {}, name), element); + const form = h('form.decision-editor', { onsubmit: async (e: Event) => { + e.preventDefault(); button.disabled = true; error.textContent = ''; + try { await save({ title: title.value, question: question.value, context: context.value, options: options.value.split('\n').map(s=>s.trim()).filter(Boolean), recommendation: recommendation.value, + category: category.value, tags: tags.value.split(',').map(s=>s.trim()).filter(Boolean), issues: issues.value.split(',').map(s=>s.trim().replace(/^#/, '')).filter(Boolean).map(Number), blocking: blocking.checked, blockingReason: reason.value }); modal.close(); } + catch (e) { error.textContent = (e as Error).message; } finally { button.disabled = false; } + } }, field('Titel', title), field('Welche Entscheidung brauchst du?', question), field('Hintergrund / Auswirkungen', context), field('Optionen (eine je Zeile)', options), field('Empfehlung mit Begründung', recommendation), + field('Kategorie', category), field('Tags (Komma getrennt)', tags), field('Abhängige Issues (Nummern, Komma getrennt)', issues), + h('label', {}, blocking, ' Blockiert die weitere Arbeit'), field('Was genau ist blockiert?', reason), error, button); + const modal = openModal(h('section.decisions-dialog', { role: 'dialog', 'aria-label': 'Entscheidungsfrage bearbeiten' }, h('header', {}, h('h2', {}, 'Entscheidungsfrage')), form)); +} diff --git a/src/client/features/decisions/index.ts b/src/client/features/decisions/index.ts new file mode 100644 index 000000000..63655653d --- /dev/null +++ b/src/client/features/decisions/index.ts @@ -0,0 +1,14 @@ +import type { Ctx } from '../../core/context'; +import { boardHint, onE } from '../../core/hint'; +import { store } from '../../state'; +import { blockedDecision } from '../../../shared/decisions'; +import { decisions } from './state'; +import { openDecisions } from './ui'; +declare module '../../world/types' { interface InteractKinds { decisions: true } } +export function installDecisions(ctx: Ctx) { + ctx.interactions.define('decisions', { reach: 2.5, hint: () => boardHint('Entscheidungen'), use: onE(() => openDecisions()) }); + const paint = () => ctx.office.decisionMonitor.render(decisions.state.items.filter(d => d.status === 'open').length, decisions.state.items.filter(blockedDecision).length); + decisions.on(paint); store.on('floor', () => { decisions.clear(); void decisions.refresh(); }); + setInterval(() => { if (store.floor) void decisions.refresh(); }, 10000); + void decisions.refresh(); +} diff --git a/src/client/features/decisions/state.ts b/src/client/features/decisions/state.ts new file mode 100644 index 000000000..e4f58eea9 --- /dev/null +++ b/src/client/features/decisions/state.ts @@ -0,0 +1,20 @@ +import { store } from '../../state'; +import type { DecisionState } from '../../../shared/decisions'; +const listeners = new Set<() => void>(); +let request = 0; +export const decisions = { + state: { items: [] } as DecisionState, + error: '', + on(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn); }; }, + clear() { request++; this.error = ''; this.state = { items: [] }; listeners.forEach(fn => fn()); }, + async refresh() { + const floor = store.floor; if (!floor) return; const current = ++request; + try { const r = await fetch('/api/decisions?floor=' + encodeURIComponent(floor)); if (!r.ok) throw Error('Pool nicht erreichbar'); + const data: DecisionState = await r.json(); if (store.floor !== floor || current !== request) return; + this.state = data; this.error = ''; listeners.forEach(fn => fn()); + } catch { if (store.floor !== floor || current !== request) return; + this.error = 'Entscheidungen können gerade nicht aktualisiert werden. Angezeigte Einträge sind möglicherweise veraltet.'; + listeners.forEach(fn => fn()); + } + }, +}; diff --git a/src/client/features/decisions/ui.css b/src/client/features/decisions/ui.css new file mode 100644 index 000000000..d6b2c9300 --- /dev/null +++ b/src/client/features/decisions/ui.css @@ -0,0 +1,22 @@ +.decisions-dialog { width: min(950px,95vw); max-height: 92vh; display:flex; flex-direction:column; background:#fffaf0; color:#303048; border:3px solid #303048; border-radius:20px; overflow:hidden; } +.decisions-dialog > header { display:flex; align-items:center; justify-content:space-between; padding:18px 20px; border-bottom:3px solid #303048; background:#fff2d9; } +.decisions-dialog h2 { margin:0; } +.decisions-body,.decision-editor { padding:20px; overflow:auto; } +.decision-actions { display:flex; gap:10px; flex-wrap:wrap; margin:12px 0; } +.decision-actions input { flex:1; min-width:180px; } +.decision-card { margin:10px 0; border:2px solid #303048; border-radius:12px; background:#fffdf6; } +.decision-card.blocked { border-color:#c6345d; background:#fff0f3; } +.decision-card > summary { padding:16px; cursor:pointer; display:flex; justify-content:space-between; gap:14px; } +.decision-label { font-size:12px; flex-shrink:0; background:#fff0ba; padding:5px 9px; border-radius:8px; } +.blocked .decision-label { background:#c6345d; color:white; } +.decision-content { padding:0 16px 16px; } +.decision-content p { white-space:pre-wrap; overflow-wrap:anywhere; line-height:1.5; } +.decision-blocker,.decision-error { color:#9c2445; } +.decision-answer { background:#d6f4df; padding:12px; border-radius:8px; } +.decision-tags,.decision-sources { display:flex; flex-wrap:wrap; gap:8px; margin:10px 0; } +.decision-tags button { border:0; padding:5px 10px; border-radius:6px; background:#e5e6ff; color:#303048; cursor:pointer; } +.decision-field { display:grid; gap:6px; margin-bottom:14px; } +.decisions-dialog textarea { width:100%; box-sizing:border-box; resize:vertical; } +.decisions-dialog input,.decisions-dialog select,.decisions-dialog textarea { border:2px solid #55516d; border-radius:8px; padding:10px; font:inherit; background:white; color:#303048; max-width:100%; } +.decisions-dialog .btn { white-space:normal; } +@media(max-width:550px) { .decisions-body,.decision-editor { padding:12px; } .decision-card > summary { flex-direction:column; } } diff --git a/src/client/features/decisions/ui.ts b/src/client/features/decisions/ui.ts new file mode 100644 index 000000000..977780310 --- /dev/null +++ b/src/client/features/decisions/ui.ts @@ -0,0 +1,72 @@ +import './ui.css'; +import { DECISION_CATEGORIES, blockedDecision, orderDecisions, type DecisionView } from '../../../shared/decisions'; +import { h, openModal, type Modal } from '../../ui/dom'; +import type { HudAction } from '../../ui/menu'; +import { store } from '../../state'; +import { decisions } from './state'; +import { editDecision } from './editor'; +let modal: Modal | undefined; +export const decisionsAction: HudAction = { id: 'decisions', icon: '💡', label: 'Entscheidungen', section: 'Open', count: () => decisions.state.items.filter(blockedDecision).length, run: () => openDecisions() }; +export function openDecisions() { + if (modal || !store.floor) return; + const floor = store.floor, drafts = new Map(), expanded = new Set(); + const list = h('div.decision-list'), summary = h('p'), error = h('p.decision-error', { role: 'alert' }), connection = h('p.decision-error', { role: 'status' }); + const search = h('input', { placeholder: 'Frage, Issue oder Tag suchen', 'aria-label': 'Entscheidungen suchen', oninput: () => render(true) }); + const category = h('select', { 'aria-label': 'Kategorie', onchange: () => render(true) }, h('option', { value: '' }, 'Alle Kategorien'), ...DECISION_CATEGORIES.map(c => h('option', { value: c }, c))); + const status = h('select', { 'aria-label': 'Status', onchange: () => render(true) }, h('option', { value: 'open' }, 'Offene Entscheidungen'), h('option', { value: 'blocked' }, 'Blockiert Arbeit'), h('option', { value: 'answered' }, 'Beantwortet'), h('option', { value: 'all' }, 'Alle')); + async function action(body: unknown) { + if (store.floor !== floor) throw Error('Projekt wurde gewechselt.'); + const r = await fetch('/api/decisions?floor=' + encodeURIComponent(floor), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const data = await r.json(); if (!r.ok) throw Error(data.error); await decisions.refresh(); return data; + } + const act = async (body: unknown) => { error.textContent = ''; try { await action(body); } catch (e) { error.textContent = (e as Error).message; } }; + function card(d: DecisionView) { + const details = h('details.decision-card', { open: expanded.has(d.id), ontoggle: (e: Event) => { if ((e.target as HTMLDetailsElement).open) expanded.add(d.id); else expanded.delete(d.id); } }); + details.classList.toggle('blocked', blockedDecision(d)); + const badges = h('div.decision-tags', {}, ...d.tags.map(t => h('button', { type: 'button', onclick: () => { search.value = t; render(true); } }, t))); + const source = h('div.decision-sources', {}, ...d.issues.map(n => decisions.state.repo ? h('a', { href: `https://github.com/${decisions.state.repo}/issues/${n}`, target: '_blank', rel: 'noopener noreferrer' }, '#' + n) : h('span', {}, '#' + n))); + const body = h('div.decision-content', {}, h('p', {}, d.question), d.context ? h('p', {}, d.context) : null, + d.blocking ? h('p.decision-blocker', {}, 'Abhängigkeit: ' + d.blockingReason) : null, + d.recommendation ? h('p', {}, 'Empfehlung: ' + d.recommendation) : null, + h('small', {}, `Erfasst von ${d.createdBy} · ${new Date(d.createdAt).toLocaleString()}`), badges, source); + if (d.status === 'answered') body.append(h('p.decision-answer', {}, `Entschieden von ${d.answeredBy}: ${d.answer}`)); + if (d.canAnswer && d.status === 'open') { + const answer = h('textarea', { rows: 3, placeholder: 'Deine Entscheidung und ggf. Bedingungen…', 'aria-label': 'Deine Entscheidung', oninput: () => drafts.set(d.id, answer.value) }, drafts.get(d.id) ?? ''); + body.append(h('div.decision-actions', {}, ...d.options.map(option => h('button.btn', { type: 'button', onclick: () => { answer.value = option; drafts.set(d.id, option); } }, option))), answer, + h('button.btn.primary', { type: 'button', onclick: async () => { if (!answer.value.trim()) { error.textContent = 'Bitte eine Entscheidung eingeben.'; return; } await act({ action: 'answer', id: d.id, revision: d.revision, answer: answer.value }); } }, 'Entscheidung speichern')); + } else if (!d.canAnswer && d.status === 'open') body.append(h('p', {}, 'Diese Frage beantwortet der zuständige Projektverantwortliche.')); + if (d.canAnswer) { + const actions = h('div.decision-actions', {}, h('button.btn', { type: 'button', onclick: () => { modal?.close(); editDecision(d, input => action({ action: 'edit', id: d.id, revision: d.revision, input }).then(()=>{})); } }, 'Bearbeiten')); + if (d.status === 'answered') { + actions.append(h('button.btn', { type: 'button', onclick: () => act({ action: 'reopen', id: d.id, revision: d.revision }) }, 'Wieder öffnen')); + for (const w of d.workers) actions.append(h('button.btn', { type: 'button', disabled: d.notified?.[w.id] === d.answeredAt, onclick: () => act({ action: 'notify', id: d.id, revision: d.revision, worker: w.id }) }, d.notified?.[w.id] === d.answeredAt ? `${w.name} informiert` : `${w.name} informieren / fortsetzen`)); + } + body.append(actions); + } + if (d.history.length) body.append(h('details', {}, h('summary', {}, 'Antwortverlauf'), ...d.history.map(a => h('p', {}, `${new Date(a.at).toLocaleString()} · ${a.by}: ${a.answer}`)))); + details.append(h('summary', {}, h('strong', {}, d.title), h('span.decision-label', {}, blockedDecision(d) ? 'Blockiert Arbeit' : d.status === 'answered' ? 'Beantwortet' : d.category)), body); return details; + } + let last = ''; + function render(force = false) { + connection.textContent = decisions.error; + const all = decisions.state.items, signature = JSON.stringify(all.map(d => [d.id,d.revision,d.canAnswer])); + summary.textContent = `${all.filter(d => d.status === 'open').length} offen · ${all.filter(blockedDecision).length} blockieren Arbeit · ${all.filter(d => d.status === 'answered').length} beantwortet`; + if (!force && signature === last) return; last = signature; + const q = search.value.toLocaleLowerCase(); + const items = orderDecisions(all).filter(d => (!category.value || d.category === category.value) && (status.value === 'all' || (status.value === 'blocked' ? blockedDecision(d) : d.status === status.value)) && `${d.title} ${d.question} ${d.tags.join(' ')} ${d.issues.map(n=>'#'+n).join(' ')}`.toLocaleLowerCase().includes(q)); + list.replaceChildren(); let previous = ''; + for (const d of items) { const group = blockedDecision(d) ? 'Zuerst entscheiden: blockiert Arbeit' : d.status === 'answered' ? 'Beantwortet · ' + d.category : d.category; + if (group !== previous) { list.append(h('h3', {}, group)); previous = group; } list.append(card(d)); } + if (!items.length) list.append(h('p', {}, 'Keine Entscheidungen für diese Auswahl.')); + } + modal = openModal(h('section.decisions-dialog', { role: 'dialog', 'aria-label': 'Entscheidungen', 'aria-modal': 'true' }, + h('header', {}, h('h2', {}, '💡 Entscheidungen')), + h('div.decisions-body', {}, h('p', {}, 'Deine Antworten für das Projekt. Speichern startet keine Aufgabe; bestehende Worker kannst du anschließend gezielt informieren.'), + h('div.decision-actions', {}, search, category, status, + h('button.btn', { type: 'button', onclick: () => decisions.refresh() }, 'Aktualisieren'), + h('button.btn', { type: 'button', onclick: () => { modal?.close(); editDecision({}, input => action({ action: 'add', input }).then(()=>{})); } }, '+ Entscheidung'), + h('button.btn', { type: 'button', onclick: async () => { try { await action({ action: 'scan' }); error.textContent = 'Issue Agent prüft Entscheidungen und Playtests. Die Liste aktualisiert sich automatisch.'; } catch(e) { error.textContent = (e as Error).message; } } }, 'Issues durchsuchen')), + summary, connection, error, list)), { onClose: () => { off(); offFloor(); clearInterval(timer); modal = undefined; } }); + const off = decisions.on(() => render()), offFloor = store.on('floor', () => { if (store.floor !== floor) modal?.close(); }); + const timer = setInterval(() => void decisions.refresh(), 5000); render(true); void decisions.refresh(); +} diff --git a/src/client/features/decisions/world.ts b/src/client/features/decisions/world.ts new file mode 100644 index 000000000..b9dc4ef1d --- /dev/null +++ b/src/client/features/decisions/world.ts @@ -0,0 +1,35 @@ +import * as THREE from 'three'; +import { bossMonitor } from '../../world/office/boss-monitor'; +import type { Fixture } from '../../world/office/fixture'; +import type { Interactable } from '../../world/types'; +export function mountDecisionMonitor(bossScreen: THREE.Mesh) { + const desk = bossScreen.parent!.parent!, built = bossMonitor(-0.52); + desk.add(built.group); desk.updateWorldMatrix(true, true); + const point = built.screen.getWorldPosition(new THREE.Vector3()); + const it: Interactable = { kind: 'decisions', x: point.x, y: point.y - 1.18, z: point.z + 0.9, radius: 1.6 }; + built.screen.userData.interact = it; built.group.userData.interact = it; + const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 360; + const c = canvas.getContext('2d')!, texture = new THREE.CanvasTexture(canvas); + const mat = built.screen.material as THREE.MeshBasicMaterial; mat.color.set('#ffffff'); mat.map = texture; + const alertCanvas = document.createElement('canvas'); alertCanvas.width = 768; alertCanvas.height = 160; + const a = alertCanvas.getContext('2d')!, alertTexture = new THREE.CanvasTexture(alertCanvas); + const alert = new THREE.Sprite(new THREE.SpriteMaterial({ map: alertTexture, depthTest: false })); + alert.position.set(0, 1.8, -0.15); alert.scale.set(1.9, 0.4, 1); built.group.add(alert); + let blocked = 0; + function render(open: number, count: number) { + blocked = count; alert.visible = count > 0; + c.fillStyle = count ? '#702440' : '#253b50'; c.fillRect(0,0,640,360); + c.textAlign = 'center'; c.fillStyle = '#fff7dc'; c.font = 'bold 48px sans-serif'; c.fillText('Entscheidungen',320,90); + c.font = 'bold 88px sans-serif'; c.fillText(String(open),320,203); + c.font = '32px sans-serif'; c.fillText(count ? `${count} ${count === 1 ? 'blockiert' : 'blockieren'} Arbeit` : 'Fragen an dich',320,275); texture.needsUpdate = true; + a.clearRect(0,0,768,160); a.fillStyle = '#e84779'; a.strokeStyle = '#303048'; a.lineWidth = 10; a.beginPath(); a.roundRect(5,5,758,150,30); a.fill(); a.stroke(); + a.fillStyle = '#fff'; a.textAlign='center'; a.font='bold 37px sans-serif'; a.fillText(`${count} Entscheidung${count === 1 ? '' : 'en'} ${count === 1 ? 'braucht' : 'brauchen'} dich`,384,92); alertTexture.needsUpdate=true; + } + render(0,0); + return { screen: built.screen, interactable: it, render, update(t: number) { alert.material.opacity = blocked ? 0.84 + Math.sin(t * 3) * 0.16 : 0; } }; +} +declare module '../../world/types' { interface OfficeHandles { decisionMonitor: ReturnType } } +export const decisionMonitor: Fixture<'decisionMonitor'> = site => { + const built = mountDecisionMonitor(site.get('bossScreen')); + return { handle: { decisionMonitor: built }, interactables: [built.interactable], update: t => built.update(t) }; +}; diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..383000982 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -3,6 +3,7 @@ * and F to hang a picture; the project in the corner (click it for the floors); Settings, and your * character. */ +import { decisionsAction } from '../decisions/ui'; import { ROOF } from '../../../shared/rooftop'; import type { Ctx } from '../../core/context'; import type { CoreState } from '../../core/ctx'; @@ -49,6 +50,7 @@ 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( [ + decisionsAction, { 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 }, diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..6f9cbb453 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -27,6 +27,7 @@ import { installHintBar } from './core/hintbar'; import { installKeyboard, installKeyGuards } from './input/keyboard'; import { installFocus } from './input/focus'; import { installPointer } from './input/pointer'; +import { installDecisions } from './features/decisions'; import { installArcade } from './features/arcade'; import { installBar } from './features/bar'; import { installBarGames } from './features/bargames'; @@ -102,6 +103,7 @@ parts.confetti = new Confetti((x, z, y) => groundAt(ctx.player.colliders, x, z, ctx.scene.add(parts.confetti.mesh); parts.tv = installTv(ctx, { shares: () => parts.talk.currentShares(), watch: () => parts.talk.watchShare() }); parts.arcade = installArcade(ctx); +installDecisions(ctx); parts.rooftop = installRooftop(ctx, { ambient: parts.stage.ambient, hemi: parts.stage.hemi }); // You, and how you talk to the office. diff --git a/src/client/world/office/boss-monitor.ts b/src/client/world/office/boss-monitor.ts new file mode 100644 index 000000000..5b8154880 --- /dev/null +++ b/src/client/world/office/boss-monitor.ts @@ -0,0 +1,11 @@ +import * as THREE from 'three'; +import { mesh, roundedBox, toon } from '../toon'; +import { box } from './materials'; +export function bossMonitor(x: number) { + const group = new THREE.Group(); group.position.x = x; + group.add(mesh(roundedBox(0.9, 0.55, 0.06, 0.03), toon('#2b2d42'), 0, 1.18, -0.2)); + group.add(mesh(box(0.08, 0.2, 0.08), toon('#2b2d42'), 0, 0.93, -0.2)); + const screen = mesh(new THREE.PlaneGeometry(0.8, 0.45), new THREE.MeshBasicMaterial({ color: '#4cc9f0' }), 0, 1.18, -0.165, false); + group.add(screen); return { group, screen }; +} + diff --git a/src/client/world/office/build.ts b/src/client/world/office/build.ts index 15c0e6237..1cfbe3b51 100644 --- a/src/client/world/office/build.ts +++ b/src/client/world/office/build.ts @@ -26,6 +26,7 @@ import { downstairs } from './ground'; import { wing } from './wing'; import { beanbags, desks, kiosks } from './seats'; import { meetingRoom } from './meeting-room'; +import { decisionMonitor } from '../../features/decisions/world'; import { loft } from './loft'; import type { Fixture, Gives, Site } from './fixture'; @@ -65,6 +66,7 @@ function floorPlan() { wing, signs, loft, + decisionMonitor, meetingRoom, elevator, garageLift, diff --git a/src/client/world/office/loft.ts b/src/client/world/office/loft.ts index a3c988f74..4c4ff79f0 100644 --- a/src/client/world/office/loft.ts +++ b/src/client/world/office/loft.ts @@ -1,3 +1,4 @@ +import { bossMonitor } from './boss-monitor'; import * as THREE from 'three'; import { LOFT, STAIRS, WALL_T } from '../../../shared/layout'; import { mesh, roundedBox, textPlane, toon } from '../toon'; @@ -120,11 +121,9 @@ export function buildLoft(group: THREE.Group, colliders: Collider[], interactabl desk.add(mesh(roundedBox(2.6, 0.1, 1.2, 0.1), woodMat, 0, 0.78, 0)); desk.add(mesh(box(2.4, 0.66, 0.08), toon('#8a5a3b'), 0, 0.4, -0.5)); for (const sx of [-1, 1]) desk.add(mesh(box(0.1, 0.72, 1.0), toon('#8a5a3b'), sx * 1.15, 0.37, 0)); - desk.add(mesh(roundedBox(0.9, 0.55, 0.06, 0.03), toon(PALETTE.ink), 0, 1.18, -0.2)); - desk.add(mesh(box(0.08, 0.2, 0.08), toon(PALETTE.ink), 0, 0.93, -0.2)); - // Minesweeper plays on it (features/arcade/ui.ts). - const screen = mesh(new THREE.PlaneGeometry(0.8, 0.45), new THREE.MeshBasicMaterial({ color: '#4cc9f0' }), 0, 1.18, -0.165, false); - desk.add(screen); + const monitor = bossMonitor(0.52); + const screen = monitor.screen; + desk.add(monitor.group); desk.add(mesh(new THREE.CylinderGeometry(0.06, 0.05, 0.12, 10), toon('#ffd166'), 0.9, 0.89, 0.15)); const plate = textPlane('👑 BOSS', { bg: '#ffd166', size: 48 }); plate.scale.multiplyScalar(0.55); diff --git a/src/server/decisions/context.ts b/src/server/decisions/context.ts new file mode 100644 index 000000000..773f8a476 --- /dev/null +++ b/src/server/decisions/context.ts @@ -0,0 +1,4 @@ +import type { Ctx } from '../office/context.js'; +import type { Floor } from '../floor.js'; +import { Decisions } from './store.js'; +export const floorDecisions = (ctx: Ctx, floor: Floor) => new Decisions(ctx.cfg.dataDir, floor.def.repo ?? floor.id); diff --git a/src/server/decisions/store.ts b/src/server/decisions/store.ts new file mode 100644 index 000000000..80c775585 --- /dev/null +++ b/src/server/decisions/store.ts @@ -0,0 +1,73 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { DECISION_CATEGORIES, orderDecisions, type Decision, type DecisionInput } from '../../shared/decisions.js'; +export class DecisionError extends Error { constructor(message: string, public status = 400) { super(message); } } +export interface Actor { name: string; owner?: string; worker?: { id: string; name: string }; admin?: boolean } +function text(value: unknown, max: number, required = false): string { + if (typeof value !== 'string' || value.length > max || (required && !value.trim())) throw new DecisionError('Text fehlt oder ist zu lang.'); + return value.trim(); +} +export function input(raw: unknown): DecisionInput { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new DecisionError('Entscheidungsfrage fehlt.'); + const b = raw as Record; + const strings = (v: unknown, count: number, length: number) => { + if (!Array.isArray(v) || v.length > count) throw new DecisionError('Ungültige Liste.'); + return [...new Set(v.map(s => text(s, length, true)))]; + }; + const issues = b.issues ?? []; + if (!Array.isArray(issues) || issues.length > 20 || issues.some(n => !Number.isSafeInteger(n) || n < 1)) throw new DecisionError('Ungültige Issue-Nummern.'); + if (!DECISION_CATEGORIES.includes(b.category as any)) throw new DecisionError('Kategorie auswählen.'); + if (typeof b.blocking !== 'boolean') throw new DecisionError('Blockierungsstatus fehlt.'); + return { title: text(b.title, 180, true), question: text(b.question, 4000, true), context: text(b.context ?? '', 6000), + options: strings(b.options ?? [], 8, 500), recommendation: text(b.recommendation ?? '', 1000), category: b.category as string, + tags: strings(b.tags ?? [], 12, 40), issues: [...new Set(issues)], blocking: b.blocking, blockingReason: text(b.blockingReason ?? '', 1000, b.blocking) }; +} +export const canAnswer = (d: Decision, actor: Actor) => !actor.worker && (actor.admin || (!d.owner && !actor.owner) || (!!d.owner && actor.owner === d.owner)); +const fingerprint = (d: DecisionInput) => JSON.stringify([d.question.trim().toLocaleLowerCase().replace(/\s+/g, ' '), [...d.issues].sort((a,b) => a-b)]); +export class Decisions { + private file: string; + constructor(dir: string, project: string) { this.file = path.join(dir, 'decisions', createHash('sha256').update(project.toLowerCase()).digest('hex').slice(0, 24) + '.json'); } + list(): Decision[] { + if (!existsSync(this.file)) return []; + const data = JSON.parse(readFileSync(this.file, 'utf8')); + if (!Array.isArray(data.items) || data.items.some((d: Decision) => !d.id || !Number.isSafeInteger(d.revision))) throw new DecisionError('Entscheidungsspeicher beschädigt; Daten wurden nicht überschrieben.', 503); + return orderDecisions(data.items); + } + private save(items: Decision[]) { + mkdirSync(path.dirname(this.file), { recursive: true }); + const temp = this.file + '.' + randomUUID() + '.tmp'; + writeFileSync(temp, JSON.stringify({ items }), { mode: 0o600 }); renameSync(temp, this.file); + } + add(raw: unknown, actor: Actor): Decision { + const value = input(raw), items = this.list(); + const existing = items.find(d => fingerprint(d) === fingerprint(value)); + if (existing) { + // Retries and repeated scans never overwrite a human answer or silently reopen it. + let changed = false; + if (actor.worker && !existing.workers.some(w => w.id === actor.worker!.id)) { + existing.workers.push(actor.worker); changed = true; + } + if (existing.status === 'open' && value.blocking && !existing.blocking) { existing.blocking = true; existing.blockingReason = value.blockingReason; changed = true; } + if (changed) { existing.revision++; existing.updatedAt = Date.now(); this.save(items); } + return existing; + } + if (items.length >= 2000) throw new DecisionError('Entscheidungspool ist voll.', 409); + const now = Date.now(); + const item: Decision = { ...value, id: randomUUID(), revision: 1, status: 'open', createdAt: now, updatedAt: now, createdBy: actor.name, + owner: actor.owner, workers: actor.worker ? [actor.worker] : [], history: [] }; + items.push(item); this.save(items); return item; + } + change(id: string, revision: number, actor: Actor, mutate: (d: Decision) => void): Decision { + const items = this.list(), item = items.find(d => d.id === id); + if (!item) throw new DecisionError('Entscheidung nicht gefunden.', 404); + if (!canAnswer(item, actor)) throw new DecisionError('Diese Entscheidung beantwortet der zuständige Mensch.', 403); + if (item.revision !== revision) throw new DecisionError('Die Entscheidung wurde inzwischen geändert. Bitte aktualisieren.', 409); + mutate(item); item.revision++; item.updatedAt = Date.now(); this.save(items); return item; + } + answer(id: string, revision: number, value: unknown, actor: Actor) { + const answer = text(value, 6000, true); + return this.change(id, revision, actor, d => { d.answer = answer; d.answeredBy = actor.name; d.answeredAt = Date.now(); d.status = 'answered'; + d.history.push({ answer, by: actor.name, at: d.answeredAt }); d.notified = {}; }); + } +} diff --git a/src/server/hooks/office-decisions.ts b/src/server/hooks/office-decisions.ts new file mode 100644 index 000000000..b6b3ca62e --- /dev/null +++ b/src/server/hooks/office-decisions.ts @@ -0,0 +1,20 @@ +import type http from 'node:http'; +import type { Ctx } from '../office/context.js'; +import { readBody, send } from '../http/util.js'; +import { DecisionError } from '../decisions/store.js'; +import { floorDecisions } from '../decisions/context.js'; +export async function officeDecisions(ctx: Ctx, req: http.IncomingMessage, res: http.ServerResponse, url: URL) { + const id = url.searchParams.get('worker') ?? '', token = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, ''); + const floor = ctx.workerFloor(id), worker = floor?.workers.authenticate(id, token); + if (!floor || !worker) return send(res, 401, { error: 'Worker-Anmeldung fehlt.' }); + try { + const pool = floorDecisions(ctx, floor); + if (req.method === 'GET') return send(res, 200, { items: pool.list() }); + if (req.method !== 'POST') return send(res, 405, { error: 'GET oder POST verwenden.' }); + const body = JSON.parse(await readBody(req, 24000)); + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new DecisionError('Entscheidungsfrage fehlt.'); + if ('answer' in body || 'status' in body || 'owner' in body) throw new DecisionError('Worker dürfen Fragen erfassen, aber keine menschlichen Antworten setzen.', 403); + const item = pool.add(body, { name: worker.name, owner: floor.workers.ownerOf(id), worker: { id, name: worker.name } }); + return send(res, 200, { item }); + } catch (e) { return send(res, e instanceof DecisionError ? e.status : e instanceof SyntaxError ? 400 : 500, { error: e instanceof DecisionError ? e.message : 'Entscheidung konnte nicht gespeichert werden.' }); } +} diff --git a/src/server/hooks/routes.ts b/src/server/hooks/routes.ts new file mode 100644 index 000000000..508c43c65 --- /dev/null +++ b/src/server/hooks/routes.ts @@ -0,0 +1,8 @@ +import { officeQueue } from './office-queue.js'; +import { officeWorkers } from './office-workers.js'; +import { officeDecisions } from './office-decisions.js'; +export const OFFICE_HOOK_ROUTES = [ + { matches: (path: string) => path === '/office/queue', handle: officeQueue }, + { matches: (path: string) => path === '/office/workers' || path.startsWith('/office/workers/'), handle: officeWorkers }, + { matches: (path: string) => path === '/office/decisions', handle: officeDecisions }, +]; diff --git a/src/server/hooks/server.ts b/src/server/hooks/server.ts index ad333aebd..c2bb52ad3 100644 --- a/src/server/hooks/server.ts +++ b/src/server/hooks/server.ts @@ -5,8 +5,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import type { Ctx } from '../office/context.js'; import { readBody, send } from '../http/util.js'; -import { officeQueue } from './office-queue.js'; -import { officeWorkers } from './office-workers.js'; +import { OFFICE_HOOK_ROUTES } from './routes.js'; import { providerHook } from '../providers/index.js'; import type { AgentProvider } from '../../shared/providers.js'; @@ -19,8 +18,8 @@ export async function startHookServer(ctx: Ctx): Promise<{ hookServer: http.Serv } catch { return send(res, 400, {}); } - if (url.pathname === '/office/queue') return officeQueue(ctx, req, res, url); - if (url.pathname === '/office/workers' || url.pathname.startsWith('/office/workers/')) return officeWorkers(ctx, req, res, url); + const officeRoute = OFFICE_HOOK_ROUTES.find(r => r.matches(url.pathname)); + if (officeRoute) return officeRoute.handle(ctx, req, res, url); // Each provider with hooks has its route, /hooks/ (see providers/). const route = url.pathname.startsWith('/hooks/') ? url.pathname.slice('/hooks/'.length) : ''; const hook = providerHook(route); diff --git a/src/server/http/routes/decisions.ts b/src/server/http/routes/decisions.ts new file mode 100644 index 000000000..051165763 --- /dev/null +++ b/src/server/http/routes/decisions.ts @@ -0,0 +1,54 @@ +import type { Route } from '../router.js'; +import { readBody, sameOrigin, send } from '../util.js'; +import { floorParam } from './files.js'; +import { floorDecisions } from '../../decisions/context.js'; +import { canAnswer, DecisionError, input, type Actor } from '../../decisions/store.js'; +import { DECISION_SCAN } from '../../../shared/decision-prompts.js'; +export const decisionsRoute: Route = { + path: '/api/decisions', auth: 'session', + async handle(ctx, { req, res, url, session }) { + const floor = floorParam(ctx, url); if (!floor) return send(res, 404, { error: 'Projekt nicht gefunden.' }); + const actor: Actor = { name: session.account?.name ?? 'Office', owner: session.account?.id, admin: !session.account || session.account.role === 'admin' }; + try { + const pool = floorDecisions(ctx, floor); + if (req.method === 'GET') return send(res, 200, { repo: floor.def.repo, items: pool.list().map(d => ({ ...d, canAnswer: !!canAnswer(d, actor) })) }); + if (req.method !== 'POST') return send(res, 405, { error: 'GET oder POST verwenden.' }); + if (!sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Im Office anmelden und dort antworten.' }); + const b = JSON.parse(await readBody(req, 24000)); + if (!b || typeof b !== 'object' || Array.isArray(b)) throw new DecisionError('Ungültige Anfrage.'); + if (b.action === 'scan') { + const station = floor.workers.list().find(w => w.deskId === 'station-issues'); + if (actor.owner && ctx.claudeFor(station?.provider ?? floor.workers.officeDefault.provider) && !ctx.signins.claudeReady(actor.owner)) { + await ctx.signins.look(actor.owner, true); + if (!ctx.signins.claudeReady(actor.owner)) throw new DecisionError('Bitte zuerst beim Anbieter des Issue Agents anmelden.', 403); + } + const result = floor.workers.station('station-issues', actor.name, DECISION_SCAN, actor.owner); + if (typeof result === 'string') throw new DecisionError(result, 409); + return send(res, 202, { sent: true }); + } + let item; + if (b.action === 'add') item = pool.add(b.input, actor); + else if (b.action === 'answer') item = pool.answer(b.id, b.revision, b.answer, actor); + else if (b.action === 'edit') { const value = input(b.input); item = pool.change(b.id, b.revision, actor, d => { + if (d.question !== value.question || JSON.stringify(d.options) !== JSON.stringify(value.options) || JSON.stringify(d.issues) !== JSON.stringify(value.issues)) { d.status = 'open'; d.notified = {}; } + Object.assign(d, value); + }); } + else if (b.action === 'reopen') item = pool.change(b.id, b.revision, actor, d => { d.status = 'open'; }); + else if (b.action === 'notify') item = pool.change(b.id, b.revision, actor, d => { + if (d.status !== 'answered' || !d.answer) throw new DecisionError('Zuerst eine Entscheidung speichern.'); + if (!d.workers.some(w => w.id === b.worker)) throw new DecisionError('Worker gehört nicht zu dieser Frage.', 403); + if (d.notified?.[b.worker] === d.answeredAt) return; + const worker = floor.workers.get(b.worker); + if (!worker || worker.kind !== 'agent') throw new DecisionError('Worker ist nicht mehr vorhanden. Die Antwort bleibt im Pool.', 409); + if (worker.status === 'working' || worker.status === 'needs_input') throw new DecisionError('Worker ist beschäftigt oder wartet im Terminal. Antwort wurde gespeichert; später erneut senden.', 409); + const prompt = `Der Mensch ${actor.name} hat Entscheidung ${d.id} beantwortet.\nFrage: ${d.question}\nAntwort: ${d.answer}\nIssues: ${d.issues.map(n => '#' + n).join(', ')}\nPrüfe office-decisions list auf weitere offene Blockaden. Setze nur deine bereits beauftragte, von dieser Antwort betroffene Arbeit fort. Keine neuen Queue-Einträge und keine anderen Tasks starten.`; + let error = floor.workers.prompt(worker.id, prompt, actor.name); + if (error === 'Worker is not running') error = floor.workers.resume(worker.id, prompt); + if (error) throw new DecisionError(error, 409); + (d.notified ??= {})[worker.id] = d.answeredAt!; + }); + else throw new DecisionError('Unbekannte Aktion.'); + return send(res, 200, { item }); + } catch (e) { return send(res, e instanceof DecisionError ? e.status : e instanceof SyntaxError ? 400 : 500, { error: e instanceof DecisionError ? e.message : 'Entscheidung konnte nicht verarbeitet werden.' }); } + }, +}; diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..2b1415dac 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -1,6 +1,7 @@ // Every HTTP route the office answers, in the order they're tried: a new route goes where it has to // come in that order (see http/router.ts). The public ones are tried first, then the sign-in check, // then the rest; the last one answers every path left with the client bundle, or a 404. +import { decisionsRoute } from './decisions.js'; import type { Route } from '../router.js'; import { agentRoutes } from './agents.js'; import { authRoutes } from './auth.js'; @@ -26,6 +27,7 @@ export const routes: readonly Route[] = [ pageRoutes.join, pageRoutes.favicon, // Signed in. + decisionsRoute, authRoutes.whoami, agentRoutes.models, fileRoutes.image, diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index b19b2b3cd..8f6dfb7a9 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -1,3 +1,4 @@ +import { workerPrompt, stationRequest } from './prompt-extensions.js'; import { randomBytes } from 'node:crypto'; import path from 'node:path'; import type { AgentChoice, AgentEffort, AgentProvider, TerminalHit, WorkerInfo, WorkerKind, WorkerRepo, WorkerStatus } from '../../shared/protocol.js'; @@ -335,7 +336,7 @@ export class WorkerManager { */ station(deskId: string, by: string, text: string, owner?: string): { info: WorkerInfo; hired: boolean } | string { if (!DESK_BY_ID.get(deskId)?.station) return 'There is no agent to ask there'; - const clean = text.replace(/\r\n?/g, '\n').trim(); + const clean = stationRequest(deskId, text.replace(/\r\n?/g, '\n').trim()); if (!clean) return 'Empty prompt'; const w = [...this.workers.values()].find((x) => x.info.deskId === deskId); if (!w) { @@ -500,6 +501,7 @@ export class WorkerManager { prompt(id: string, text: string, by?: string): string | undefined { const w = this.workers.get(id); if (!w) return 'No such worker'; + if (w.info.kind === 'agent') text = workerPrompt(text)!; if (w.dsh) { const clean = text.replace(/\r\n?/g, '\n').trim(); if (!clean) return 'Empty prompt'; @@ -522,7 +524,6 @@ export class WorkerManager { this.emitUpdate(w); return undefined; } - /** Pushes a worktree worker's branch and opens a pull request for it, as `as` or else the office (see WorkerPrs.openPr). */ openPr(id: string, by: string, as?: GhAs): Promise<{ prs: OpenedPr[]; failed: string[] } | string> { return this.prs.openPr(id, by, as); @@ -635,15 +636,14 @@ export class WorkerManager { private launch(w: Worker, prompt: string | undefined, resumeSessionId: string | undefined) { const { info } = w; - // Its folder was deleted meanwhile: it waits, marked lost, for someone to rebuild it or send it home. + if (w.info.kind === 'agent') prompt = workerPrompt(prompt); if (this.worktrees.checkLost(w)) { clockWork(info, 'exited'); info.status = 'exited'; this.emitUpdate(w); return; } - // The new terminal starts with what the last one showed (on a resume), or with what was saved - // when the office last stopped, so earlier output is still there to scroll back to and search. + // Keep earlier terminal output when resuming. const restarted = !w.term; const before = w.term && w.ser ? terminalTail(w.term, w.ser, SCROLLBACK) : this.scrollback.load(info.id); const prelude = before ? `${before}\r\n${restarted ? RESTORED_NOTE : ''}` : undefined; diff --git a/src/server/workers/process.ts b/src/server/workers/process.ts index 2e5446aed..cf858c8c4 100644 --- a/src/server/workers/process.ts +++ b/src/server/workers/process.ts @@ -87,7 +87,7 @@ export function shq(s: string) { export function writeOfficeCommands(dataDir: string): string | undefined { const dir = path.join(dataDir, 'bin'); let wrote = false; - for (const [name, what] of [['office-queue', "Agent Office's task queue, for the board agents"], ['office-workers', "Agent Office's workers, for every worker"]]) { + for (const [name, what] of [['office-decisions', 'Human decision pool'], ['office-queue', "Agent Office's task queue, for the board agents"], ['office-workers', "Agent Office's workers, for every worker"]]) { const script = binScript(`${name}.js`); if (!script) continue; mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/src/server/workers/prompt-extensions.ts b/src/server/workers/prompt-extensions.ts new file mode 100644 index 000000000..5cf84e9fa --- /dev/null +++ b/src/server/workers/prompt-extensions.ts @@ -0,0 +1,8 @@ +import { DECISION_HANDOFF } from '../../shared/decision-prompts.js'; +// Cross-provider launch extensions. Modules own their policy; the manager only composes them. +const EXTENSIONS = [DECISION_HANDOFF]; +export function workerPrompt(prompt: string | undefined): string | undefined { + if (!prompt?.trim() || prompt.startsWith('/')) return prompt; + return [prompt, ...EXTENSIONS.filter(text => !prompt.includes(text))].join('\n\n'); +} +export const stationRequest = (desk: string, text: string) => desk === 'station-issues' ? workerPrompt(text)! : text; diff --git a/src/shared/decision-prompts.ts b/src/shared/decision-prompts.ts new file mode 100644 index 000000000..f9751a286 --- /dev/null +++ b/src/shared/decision-prompts.ts @@ -0,0 +1,6 @@ +export const DECISION_HANDOFF = `Human decisions belong in the Office decision pool, separate from playtests and the work queue. +Whenever you scan issues for human playtests, also inspect issue bodies and comments for unresolved decisions for the owner. Include decisions discovered during implementation or meetings. Read the context; an issue being assigned to a human alone is NOT evidence that a decision is needed. +Use office-decisions list first, then office-decisions add with JSON on stdin: title, question (one precise question), context, options (strings), recommendation, category, tags, issues (numbers), blocking (boolean), blockingReason. Categories: Spielablauf, Design & UX, Technik, Assets, Release, Organisation, Sonstiges. Tags describe mode and topic, such as RB, RIFT, Waffen, Solo, Koop. Reuse matching questions; keep distinct questions separate. Include concrete alternatives and consequences when known, never invent a human answer. +Set blocking=true ONLY when further work on the linked issue actually depends on this answer; explain the dependency. Pause only that dependent work, preserve its files, and continue independent work where possible. An unrelated playtest is not a blocker. Do not call blocked work finished or merge it as completed. Do not silently choose the owner's answer. +The human answers at the decisions monitor in the Boss Office. Read office-decisions list again before continuing dependent work; an answer is scope-specific, not permission to expand it. Recording or answering a decision NEVER enqueues, assigns, hires, restarts or dispatches tasks. Only the user decides which issues enter the queue and when. Keep mixed implementation issues open; a pool entry alone is not implementation or permission to close an issue. Manual playtests stay on office-playtests, never in the decision pool. Report the saved decision IDs and linked issues; verify they appear in office-decisions list.`; +export const DECISION_SCAN = `Prüfe alle offenen und laufenden Issues dieses Projekts einschließlich ihrer Kommentare auf offene Entscheidungen für den menschlichen Projektverantwortlichen UND auf manuelle Playtests. Erfasse konkrete Entscheidungsfragen mit office-decisions und manuelle Tests mit office-playtests, falls dieses Werkzeug eingerichtet ist. Prüfe vorher vorhandene Einträge und vermeide Duplikate. Markiere echte Arbeitsblockaden mit Begründung und Issue-Nummern. Behalte Entwicklungsarbeit im Issue. Keine Issues in die Queue stellen, zuweisen, starten oder automatisch schließen. Keine Antworten erfinden. Führe die Erfassung tatsächlich aus und lies danach die gespeicherten Einträge zur Kontrolle erneut.\n\n${DECISION_HANDOFF}`; diff --git a/src/shared/decisions.ts b/src/shared/decisions.ts new file mode 100644 index 000000000..e46ae5655 --- /dev/null +++ b/src/shared/decisions.ts @@ -0,0 +1,28 @@ +export const DECISION_CATEGORIES = ['Spielablauf', 'Design & UX', 'Technik', 'Assets', 'Release', 'Organisation', 'Sonstiges'] as const; +export interface DecisionInput { + title: string; + question: string; + context: string; + options: string[]; + recommendation: string; + category: string; + tags: string[]; + issues: number[]; + blocking: boolean; + blockingReason: string; +} +export interface Decision extends DecisionInput { + id: string; revision: number; status: 'open' | 'answered'; + createdAt: number; updatedAt: number; createdBy: string; owner?: string; + workers: { id: string; name: string }[]; + answer?: string; answeredBy?: string; answeredAt?: number; + history: { answer: string; by: string; at: number }[]; + notified?: Record; +} +export interface DecisionView extends Decision { canAnswer: boolean } +export interface DecisionState { items: DecisionView[]; repo?: string } +export const blockedDecision = (d: Decision) => d.status === 'open' && d.blocking; +export function orderDecisions(items: readonly T[]): T[] { + return [...items].sort((a, b) => Number(blockedDecision(b)) - Number(blockedDecision(a)) || + Number(a.status === 'answered') - Number(b.status === 'answered') || a.category.localeCompare(b.category, 'de') || a.createdAt - b.createdAt || a.id.localeCompare(b.id)); +} diff --git a/tests/decisions.test.ts b/tests/decisions.test.ts new file mode 100644 index 000000000..67f162a0d --- /dev/null +++ b/tests/decisions.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import http from 'node:http'; +import { Decisions, input } from '../src/server/decisions/store.js'; +import { orderDecisions } from '../src/shared/decisions.js'; +import { decisionsRoute } from '../src/server/http/routes/decisions.js'; +import { officeDecisions } from '../src/server/hooks/office-decisions.js'; +import { requestHandler } from '../src/server/http/router.js'; +import { workerPrompt, stationRequest } from '../src/server/workers/prompt-extensions.js'; +import { WorkerManager } from '../src/server/workers/manager.js'; +const owner = { name: 'Owner', owner: 'owner' }, worker = { name: 'Pixel', owner: 'owner', worker: { id: 'w1', name: 'Pixel' } }; +const question = { title: 'RB: Kosten des Upgrades', question: 'Soll das Upgrade 10 oder 20 Münzen kosten?', context: 'Der Shop benötigt den Preis.', options: ['10 Münzen', '20 Münzen'], recommendation: '10 für die erste Runde', category: 'Spielablauf', tags: ['RB', 'Shop'], issues: [303], blocking: true, blockingReason: 'Die Preisimplementierung wartet auf die Auswahl.' }; +function setup(t: any) { const dir = mkdtempSync(path.join(tmpdir(), 'office-decisions-')); t.after(() => rmSync(dir, {recursive:true,force:true})); return { dir, pool: new Decisions(dir, 'owner/game') }; } +test('deduplicates repeated questions, preserves answers and restart state, isolates projects', t => { + const {dir,pool} = setup(t), first=pool.add(question,worker); + const duplicate=pool.add({...question,title:'Anderer Titel'},worker); assert.equal(duplicate.id,first.id); + const answered=pool.answer(first.id,first.revision,'10 Münzen',owner); + assert.equal(pool.add(question,worker).status,'answered'); assert.equal(pool.list().length,1); + assert.equal(new Decisions(dir,'owner/game').list()[0].answer,'10 Münzen'); + assert.equal(new Decisions(dir,'other/game').list().length,0); + assert.equal(answered.history.length,1); +}); +test('human owner answers; workers and unrelated users cannot; stale revisions never lose an answer', t => { + const {pool}=setup(t), d=pool.add(question,worker); + assert.throws(()=>pool.answer(d.id,d.revision,'x',worker),/zuständige/); + assert.throws(()=>pool.answer(d.id,d.revision,'x',{name:'Other',owner:'other'}),/zuständige/); + assert.throws(()=>pool.answer(d.id,d.revision,' ',owner),/fehlt/); + pool.answer(d.id,d.revision,'10',owner); + assert.throws(()=>pool.answer(d.id,d.revision,'20',owner),/inzwischen/); + assert.equal(pool.list()[0].answer,'10'); +}); +test('real blockades escalate existing open questions and sort ahead of every category', t => { + const {pool}=setup(t); + const low=pool.add({...question,blocking:false,blockingReason:'',category:'Assets'},worker); + const other=pool.add({...question,question:'Andere Frage?',category:'Assets',blocking:false,blockingReason:''},worker); + const high=pool.add(question,worker); assert.equal(high.id,low.id); assert.equal(high.blocking,true); + assert.equal(orderDecisions([other,high])[0].id,high.id); + const answered=pool.answer(high.id,high.revision,'10',owner); + assert.equal(orderDecisions([answered,other])[0].id,other.id); + assert.throws(()=>input({...question,blockingReason:''}),/fehlt/); +}); +test('launch and issue station extensions apply to custom requests without duplication or rewriting slash commands', () => { + const p=workerPrompt('Mein Auftrag')!; assert.ok(p.startsWith('Mein Auftrag')); assert.match(p,/office-decisions add/); assert.match(p,/Only the user decides/); + assert.equal(workerPrompt(p),p); assert.equal(workerPrompt(undefined),undefined); assert.equal(workerPrompt('/status'),'/status'); + assert.match(stationRequest('station-issues','Playtests suchen'),/also inspect issue bodies and comments/); + assert.equal(stationRequest('station-pulls','Review'),'Review'); +}); +test('authenticated API saves answers without queue changes; explicit notification is scoped and idempotent', async t => { + const {dir,pool}=setup(t); const d=pool.add(question,worker); let notifications=0, scans=0, workStatus='done'; + const floor={id:'game',def:{repo:'owner/game'},workers:{ get:()=>({id:'w1',kind:'agent',status:workStatus}), prompt:()=>{notifications++;}, resume:()=>{throw Error('No automatic restart');}, list:()=>[], officeDefault:{provider:'codex'},station:(_desk:string,_by:string,prompt:string)=>{scans++;assert.match(prompt,/office-decisions/);assert.match(prompt,/office-playtests/);return {info:{id:'issue-agent'}};} }}; + const ctx={cfg:{dataDir:dir,port:0,trustProxy:false},services:{lookup:()=>undefined},auth:{fromRequest:(req:http.IncomingMessage)=>req.headers.cookie==='owner'?{account:{id:'owner',name:'Owner',role:'member'}}:undefined},floors:new Map([['game',floor]]),claudeFor:()=>false} as any; + const server=http.createServer(requestHandler(ctx,[decisionsRoute]));await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>{server.closeAllConnections();server.close();}); + const origin=`http://127.0.0.1:${(server.address() as any).port}`,url=origin+'/api/decisions?floor=game',headers={cookie:'owner',origin,'content-type':'application/json'}; + const post=(body:unknown)=>fetch(url,{method:'POST',headers,body:JSON.stringify(body)}); + assert.equal((await fetch(url)).status,401); + assert.equal((await fetch(url,{method:'POST',headers:{cookie:'owner'},body:'{}'})).status,403); + assert.equal((await post({action:'answer',id:d.id,revision:d.revision,answer:'10'})).status,200);assert.equal(notifications,0); + const answer=pool.list()[0]; + assert.equal((await post({action:'notify',id:d.id,revision:answer.revision,worker:'foreign'})).status,403); + workStatus='needs_input';assert.equal((await post({action:'notify',id:d.id,revision:answer.revision,worker:'w1'})).status,409);assert.equal(notifications,0); + workStatus='done';assert.equal((await post({action:'notify',id:d.id,revision:answer.revision,worker:'w1'})).status,200);assert.equal(notifications,1); + assert.equal((await post({action:'notify',id:d.id,revision:pool.list()[0].revision,worker:'w1'})).status,200);assert.equal(notifications,1); + assert.equal((await post({action:'scan'})).status,202);assert.equal(scans,1); + assert.equal((await post({action:'edit',id:d.id,revision:pool.list()[0].revision,input:{...question,question:'Andere Auswahl?'}})).status,200); + assert.equal(pool.list()[0].status,'open');assert.equal(pool.list()[0].history[0].answer,'10'); +}); +test('worker hook authenticates, chooses worker floor, and cannot store a fabricated human answer', async t => { + const {dir,pool}=setup(t); + const floor={id:'game',def:{repo:'owner/game'},workers:{authenticate:(id:string,token:string)=>id==='w1'&&token==='secret'?{id,name:'Pixel'}:undefined,ownerOf:()=> 'owner'}}; + const ctx={cfg:{dataDir:dir},workerFloor:(id:string)=>id==='w1'?floor:undefined} as any; + const server=http.createServer((req,res)=>officeDecisions(ctx,req,res,new URL(req.url!,'http://local')));await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>{server.closeAllConnections();server.close();}); + const url=`http://127.0.0.1:${(server.address() as any).port}/office/decisions?worker=w1&floor=other`,headers={authorization:'Bearer secret','content-type':'application/json'}; + assert.equal((await fetch(url)).status,401); + assert.equal((await fetch(url,{method:'POST',headers,body:JSON.stringify({...question,answer:'fake'})})).status,403); + assert.equal((await fetch(url,{method:'POST',headers,body:JSON.stringify(question)})).status,200); + assert.equal(pool.list()[0].workers[0].id,'w1');assert.equal(pool.list()[0].owner,'owner'); + assert.equal((await fetch(url,{method:'POST',headers,body:'null'})).status,400); + assert.equal((await fetch(url,{method:'DELETE',headers})).status,405); +}); + +test('already running workers receive handoff instructions with their next Office task, preserving terminal commands', t => { + t.mock.timers.enable({apis:['setTimeout']}); + const written: string[] = [], w={info:{id:'active',kind:'agent'},pty:{write:(s:string)=>written.push(s)}}; + const manager={workers:new Map([['active',w]]),tasks:{notePrompt:()=>{}},emitUpdate:()=>{}}; + assert.equal(WorkerManager.prototype.prompt.call(manager as any,'active','Prüfe den Shop','Owner'),undefined); + assert.ok(written[0].startsWith('\x1b[200~Prüfe den Shop\n\n')); + assert.match(written[0],/office-decisions add/);t.mock.timers.tick(120);assert.equal(written[1],'\r'); + written.length=0;WorkerManager.prototype.prompt.call(manager as any,'active','/status','Owner'); + assert.equal(written[0],'\x1b[200~/status\x1b[201~'); +}); diff --git a/tests/pi.test.ts b/tests/pi.test.ts index f1dcaf59d..476721ffb 100644 --- a/tests/pi.test.ts +++ b/tests/pi.test.ts @@ -109,7 +109,9 @@ test('Pi worker launches, authenticates hooks, resumes its own session, and rest const first = launches[0].opts; assert.equal(first.file, file); assert.ok(first.args.includes(path.join(data, 'pi-sessions', worker.id))); - assert.deepEqual(first.args.slice(-6), ['--model', 'openai/gpt-4.1', '--thinking', 'high', '--', '- fix login']); + assert.deepEqual(first.args.slice(-6, -1), ['--model', 'openai/gpt-4.1', '--thinking', 'high', '--']); + assert.ok(first.args.at(-1).startsWith('- fix login\n\n')); + assert.match(first.args.at(-1), /office-decisions add/); assert.match(readFileSync(writePiExtension(data), 'utf8'), /agent_settled/); const token = first.env.AGENT_OFFICE_HOOK_TOKEN; const piHook = (id: string, key: string, payload: unknown) => workers.handleProviderHook('pi', id, key, '', payload);