diff --git a/src/core/schema.ts b/src/core/schema.ts index 722f882..88b592b 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -128,6 +128,27 @@ export const AgentConfigSchema = z.object({ budgetEur: z.number().nonnegative().optional(), }); +/** + * One node of the team org chart. Supports arbitrary depth via `parentId`, and + * decouples the DISPLAY (label/title/editable) from the linked roster agent, so + * org-only nodes (a human CEO, a Product Owner) can sit in the tree without a + * running agent. Live state (active/reviewing) is derived from `agent`. + */ +export const OrgNodeSchema = z.object({ + id: z.string().min(1), + /** Editable display name. */ + label: z.string().min(1), + /** One-line "what this node does" — shown behind the info button. */ + title: z.string().optional(), + /** Linked roster agent name (for model/provider/logo + live task state). */ + agent: z.string().optional(), + /** Parent node id; omit/undefined for the root. */ + parentId: z.string().optional(), + /** Provider override for the icon when there is no linked agent. */ + kind: z.string().optional(), +}); +export type OrgNode = z.infer; + export const ConfigSchema = z.object({ version: z.literal('1'), projectName: z.string().min(1), @@ -135,6 +156,8 @@ export const ConfigSchema = z.object({ roles: z.record(z.string(), RoleConfigSchema), /** Named team roster: agent name → role + model + provider. */ agents: z.record(z.string(), AgentConfigSchema).optional(), + /** Team org chart (arbitrary depth). When present, /team renders this tree. */ + org: z.array(OrgNodeSchema).optional(), serverUrl: z.string().url().optional(), }); diff --git a/src/server/routes.ts b/src/server/routes.ts index 91778eb..9384455 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -9,7 +9,7 @@ import { getStatus, updateStatus } from '../core/services/statusService.js'; import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js'; import { computeBudget } from '../core/services/budgetService.js'; import { getRoster } from '../core/services/rosterService.js'; -import { loadConfig } from '../core/config.js'; +import { loadConfig, saveConfig } from '../core/config.js'; import { renderActivityHtml } from './activity.js'; import { renderBoardHtml } from './board.js'; import { renderTeamHtml } from './team.js'; @@ -143,6 +143,19 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise // Team roster (JSON) — used by the board's agent rail / drop targets. app.get('/agents', async () => getRoster(cwd)); + // Rename an org-chart node's display label (inline edit on /team). + app.patch('/org/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const { label } = (request.body ?? {}) as { label?: string }; + if (!label || typeof label !== 'string' || !label.trim()) return badRequest(reply, 'label is required'); + const config = loadConfig(cwd); + const node = config.org?.find((n) => n.id === id); + if (!node) return notFound(reply, 'Org node'); + node.label = label.trim().slice(0, 60); + saveConfig(cwd, config); + return { ok: true, id, label: node.label }; + }); + // Token & cost rollup per agent (real recorded + time-estimated, clearly flagged). app.get('/budget', async () => computeBudget(cwd)); diff --git a/src/server/team.ts b/src/server/team.ts index 3a2af55..38d61b4 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -1,70 +1,67 @@ /** * Team org-chart page, served at `GET /team`. * - * Roster-driven org chart: the architect at the root, every worker below, with - * an SVG connector layer. The green "traveling pulse" is EVENT-DRIVEN — it is - * not a decorative loop. It visualises real task flow: - * • delegation (a task goes to an agent) → pulse flows architect → agent, - * and the agent's card turns "active" (green ring + badge); - * • review (an agent submits work) → amber pulse flows agent → architect. - * State (active / reviewing / free) is live via SSE. At rest the chart only - * breathes faintly — motion means something happened. + * Renders `config.org` — an arbitrary-depth hierarchy (CEO → architect → + * product owners → implementers, strategist → compliance, …). Falls back to a + * synthesised 2-level tree (architect → workers) when no org is configured. + * + * The green "traveling pulse" is EVENT-DRIVEN and flows along the real tree + * edges: a delegation (task → an agent) sends a pulse DOWN the path from the + * architect to that agent's node, lighting each node it passes and turning the + * target "active"; a review submission sends an amber pulse back UP. Node names + * are editable inline (persisted via PATCH /org/:id) and each node has an info + * button describing its job. Live state comes over SSE. */ import { loadConfig } from '../core/config.js'; import { listTasks } from '../core/services/taskService.js'; import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js'; -import type { Task } from '../core/schema.js'; +import type { Config, OrgNode, Task } from '../core/schema.js'; -type TaskRow = Pick; +type TaskRow = Pick; +interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; } -interface RosterAgent { - name: string; - role: string; - model?: string; - kind?: string; - description?: string; - state?: 'active' | 'reviewing'; - stateTask?: TaskRow; -} - -const ROLE_ORDER: Record = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; -const PROVIDER_ORDER: Record = { anthropic: 0, openai: 1, moonshot: 2 }; - -function gatherRoster(cwd: string): RosterAgent[] { - const config = loadConfig(cwd); - if (config.agents && Object.keys(config.agents).length > 0) { - return Object.entries(config.agents).map(([name, a]) => ({ - name, - role: a.role, - model: a.model, - kind: a.kind, - description: a.description, - })); - } - return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); -} - -/** Attach live state: in_progress → active, else a submitted-for-review task → reviewing. */ -function attachState(cwd: string, roster: RosterAgent[]): RosterAgent[] { +/** Per-agent live state for the initial server render (in_progress → active). */ +function computeStates(cwd: string): Map { const tasks = listTasks(cwd) as TaskRow[]; const active = new Map(); const review = new Map(); for (const t of tasks) { if (!t.assignedTo) continue; - if (t.status === 'in_progress') { - const e = active.get(t.assignedTo); - if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); - } else if (t.status === 'review') { - const e = review.get(t.assignedTo); - if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); - } + if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); } + else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); } } - return roster.map((a) => { - if (active.has(a.name)) return { ...a, state: 'active', stateTask: active.get(a.name) }; - if (review.has(a.name)) return { ...a, state: 'reviewing', stateTask: review.get(a.name) }; - return a; - }); + const out = new Map(); + for (const [name, task] of active) out.set(name, { state: 'active', task }); + for (const [name, task] of review) if (!out.has(name)) out.set(name, { state: 'reviewing', task }); + return out; +} + +const ROLE_ORDER: Record = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; + +/** Build the org tree from config, synthesising a flat one if none is set. */ +function resolveOrg(config: Config): { nodes: OrgNode[]; rootId: string } { + if (config.org && config.org.length) { + const root = config.org.find((n) => !n.parentId) ?? config.org[0]; + return { nodes: config.org, rootId: root.id }; + } + // Fallback: architect at the root, everyone else below it. + const roster: Array<{ name: string; role: string; kind?: string }> = config.agents + ? Object.entries(config.agents).map(([name, a]) => ({ name, role: a.role, kind: a.kind })) + : Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); + const arch = roster.find((r) => r.role === 'architect') ?? roster[0]; + const rootId = 'root:' + (arch ? arch.name : 'architect'); + const nodes: OrgNode[] = []; + const seen = new Set(); + if (arch) { nodes.push({ id: rootId, label: arch.name, agent: arch.name, kind: arch.kind, title: 'architect' }); seen.add(rootId); } + roster + .filter((r) => r !== arch) + .sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || a.name.localeCompare(b.name)) + .forEach((r, i) => { + const id = 'n:' + r.name + ':' + i; + nodes.push({ id, label: r.name, agent: r.name, kind: r.kind, parentId: rootId, title: r.role }); + }); + return { nodes, rootId }; } function monogram(name: string): string { @@ -72,55 +69,72 @@ function monogram(name: string): string { return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?'); } -function statusMarkup(a: RosterAgent): string { - if (a.state === 'active' && a.stateTask) { - return `${escapeHtml(a.stateTask.id)} · active`; - } - if (a.state === 'reviewing' && a.stateTask) { - return `${escapeHtml(a.stateTask.id)} · in review`; - } - return `free`; -} - -function node(a: RosterAgent, isArchitect = false): string { - const meta = providerMeta(a.kind); - const icon = (a.kind ? providerLogo(a.kind, 18) : '') || `${monogram(a.name)}`; - const modelColor = meta ? meta.color : 'var(--muted)'; - const badge = - a.state === 'active' - ? 'active' - : a.state === 'reviewing' - ? 'review' - : ''; - const cls = `node agent-card${isArchitect ? ' architect' : ''}${a.state ? ` ${a.state}` : ''}`; - return `
-
- ${icon} - - ${escapeHtml(a.name)} - ${escapeHtml(a.role)}${a.model ? ` · ${escapeHtml(a.model)}` : ''} - - ${badge} -
-
${statusMarkup(a)}
-
`; -} - export function renderTeamHtml(cwd: string): string { const config = loadConfig(cwd); - const roster = attachState(cwd, gatherRoster(cwd)); + const { nodes, rootId } = resolveOrg(config); + const states = computeStates(cwd); - const architects = roster.filter((a) => a.role === 'architect').sort((a, b) => a.name.localeCompare(b.name)); - const workers = roster - .filter((a) => a.role !== 'architect') - .sort( - (a, b) => - (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || - (PROVIDER_ORDER[a.kind ?? ''] ?? 9) - (PROVIDER_ORDER[b.kind ?? ''] ?? 9) || - a.name.localeCompare(b.name), - ); - const root = architects.length ? architects : workers.slice(0, 1); - const rest = architects.length ? workers : workers.slice(1); + const byId = new Map(); + for (const n of nodes) byId.set(n.id, n); + const childrenOf = new Map(); + for (const n of nodes) { + if (n.parentId) { + const l = childrenOf.get(n.parentId) ?? []; + l.push(n); + childrenOf.set(n.parentId, l); + } + } + + const agentCfg = config.agents ?? {}; + + function renderNode(n: OrgNode): string { + const linked = n.agent ? agentCfg[n.agent] : undefined; + const kind = n.kind ?? linked?.kind; + const meta = providerMeta(kind); + const icon = (kind ? providerLogo(kind, 18) : '') || `${monogram(n.label)}`; + const role = linked?.role; + const model = linked?.model; + const modelColor = meta ? meta.color : 'var(--muted)'; + const sub = [role ? escapeHtml(role) : '', model ? `${escapeHtml(model)}` : ''] + .filter(Boolean) + .join(' · '); + const title = n.title ?? linked?.description ?? ''; + const st = n.agent ? states.get(n.agent) : undefined; + const stateClass = st ? ` ${st.state}` : ''; + const badge = st ? `${st.state === 'active' ? 'active' : 'review'}` : ''; + const status = st + ? st.state === 'active' + ? `${escapeHtml(st.task.id)} · active` + : `${escapeHtml(st.task.id)} · in review` + : `free`; + return `
+
+ ${icon} + + ${escapeHtml(n.label)} + ${sub ? `${sub}` : ''} + + ${badge} + ${title ? `` : ''} +
+ ${title ? `` : ''} +
${status}
+
`; + } + + function renderSubtree(id: string): string { + const n = byId.get(id); + if (!n) return ''; + const kids = (childrenOf.get(id) ?? []).slice().sort((a, b) => { + const ra = a.agent ? agentCfg[a.agent]?.role : undefined; + const rb = b.agent ? agentCfg[b.agent]?.role : undefined; + return (ROLE_ORDER[ra ?? ''] ?? 5) - (ROLE_ORDER[rb ?? ''] ?? 5) || a.label.localeCompare(b.label); + }); + const childrenHtml = kids.length + ? `
${kids.map((k) => renderSubtree(k.id)).join('')}
` + : ''; + return `
${renderNode(n)}${childrenHtml}
`; + } return ` @@ -131,43 +145,45 @@ export function renderTeamHtml(cwd: string): string { AgentHub Team ${pageHeader(config.projectName, 'team')} -
- -
${root.map((a) => node(a, true)).join('')}
-
${rest.map((a) => node(a)).join('')}
-
+
+
+ + ${renderSubtree(rootId)} +
+