Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
16 changes: 16 additions & 0 deletions bin/office-decisions.js
Original file line number Diff line number Diff line change
@@ -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; });
2 changes: 2 additions & 0 deletions docs/code-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 38 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
@@ -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/<project-hash>.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.
26 changes: 26 additions & 0 deletions src/client/features/decisions/editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { DECISION_CATEGORIES, type DecisionInput } from '../../../shared/decisions';
import { h, openModal } from '../../ui/dom';
export function editDecision(value: Partial<DecisionInput>, save: (input: DecisionInput) => Promise<void>) {
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));
}
14 changes: 14 additions & 0 deletions src/client/features/decisions/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}
20 changes: 20 additions & 0 deletions src/client/features/decisions/state.ts
Original file line number Diff line number Diff line change
@@ -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());
}
},
};
22 changes: 22 additions & 0 deletions src/client/features/decisions/ui.css
Original file line number Diff line number Diff line change
@@ -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; } }
Loading