feat(team): roster-driven team page — provider grouping + brand logos (TSK-0028)

Architect implementation of the reopened TSK-0028 (option B). The team page now
reads config.agents (the roster) as the source of truth instead of inferring
role/identity from tasks:
- one fixed role per agent (fixes "codex in architect+implementer+tester")
- implementers grouped by provider with official Simple Icons brand logos
  (Anthropic / OpenAI / Moonshot) + the model behind each agent (Opus 4.8,
  Sonnet 4.6, GPT-5 Codex, Kimi K2)
- demo/throwaway agents gone (not in the roster)
- architect tier (claude) on top, implementer provider columns, tester tier
  (ahmed); free/busy + live timer from in-progress tasks
- ui-shared: providerLogo()/providerMeta() with verified brand SVG paths

Bump 0.7.4 -> 0.7.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-29 02:24:00 +02:00
parent b004e9fbb4
commit 4672677bf5
4 changed files with 168 additions and 176 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "0.7.4", "version": "0.7.5",
"description": "Local coordination layer for AI coding agents", "description": "Local coordination layer for AI coding agents",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@ -116,7 +116,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
export function createProgram(cwd: string): Command { export function createProgram(cwd: string): Command {
const program = new Command('agenthub') const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents') .description('Local coordination layer for AI coding agents')
.version('0.7.4') .version('0.7.5')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)'); .option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program program

View File

@ -1,8 +1,13 @@
/** /**
* Team hierarchy page, served at `GET /team`. * Team hierarchy page, served at `GET /team`.
* *
* Renders a role tree (architect on top, implementer/tester below) with one * Roster-driven: roles, model + provider come from `config.agents` (the named
* card per configured agent. Free/busy state is derived from in-progress tasks. * team roster) NOT inferred from whichever tasks an agent touched. So each
* agent has exactly one role (no more "codex in three roles"), demo/throwaway
* agents that aren't in the roster never show, and the model behind each agent
* is explicit. Implementers are grouped by provider (Anthropic / OpenAI /
* Moonshot) with the official brand logo. Free/busy is derived from in-progress
* tasks. Falls back to the role config for projects without a roster.
*/ */
import { loadConfig } from '../core/config.js'; import { loadConfig } from '../core/config.js';
@ -13,187 +18,162 @@ import {
escapeHtml, escapeHtml,
liveTimerJs, liveTimerJs,
pageHeader, pageHeader,
providerLogo,
providerMeta,
statusPill, statusPill,
} from './ui-shared.js'; } from './ui-shared.js';
import type { Task } from '../core/schema.js'; import type { Task } from '../core/schema.js';
type TaskStatus = Task['status'];
interface AgentView { /** The task fields the index actually carries + that this page needs. */
type TaskRow = Pick<Task, 'id' | 'title' | 'status' | 'assignedTo' | 'createdAt' | 'updatedAt'>;
interface RosterAgent {
name: string; name: string;
role: string; role: string;
isArchitect: boolean; model?: string;
busyTask?: Task; kind?: string;
description?: string;
busyTask?: TaskRow;
} }
const ROLE_ORDER: Record<string, number> = { const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 };
architect: 0, const PROVIDER_ORDER: Record<string, number> = { anthropic: 0, openai: 1, moonshot: 2 };
implementer: 1,
reviewer: 2,
tester: 3,
};
function sortRoles(roles: string[]): string[] { /** Build the roster from config.agents, or fall back to the role config. */
return [...roles].sort((a, b) => { function gatherRoster(cwd: string): RosterAgent[] {
const oa = ROLE_ORDER[a] ?? 99;
const ob = ROLE_ORDER[b] ?? 99;
return oa - ob;
});
}
function gatherAgents(cwd: string): AgentView[] {
const config = loadConfig(cwd); const config = loadConfig(cwd);
const byRole = new Map<string, string[]>(); if (config.agents && Object.keys(config.agents).length > 0) {
return Object.entries(config.agents).map(([name, a]) => ({
for (const [role, cfg] of Object.entries(config.roles)) { name,
const agents = byRole.get(role) ?? []; role: a.role,
if (!agents.includes(cfg.preferredAgent)) { model: a.model,
agents.push(cfg.preferredAgent); kind: a.kind,
description: a.description,
}));
} }
byRole.set(role, agents); // Fallback: no roster configured — derive one agent per role from config.roles.
return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
} }
// Also surface agents that currently have tasks assigned, even if not in config. function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] {
const tasks = listTasks(cwd) as Array<{ const tasks = listTasks(cwd) as TaskRow[];
id: string; const busyByAgent = new Map<string, TaskRow>();
status: TaskStatus;
assignedTo?: string;
role?: string;
}>;
for (const t of tasks) {
if (!t.assignedTo || !t.role) continue;
const agents = byRole.get(t.role) ?? [];
if (!agents.includes(t.assignedTo)) {
agents.push(t.assignedTo);
byRole.set(t.role, agents);
}
}
const agents: AgentView[] = [];
for (const role of sortRoles(Array.from(byRole.keys()))) {
const names = byRole.get(role) ?? [];
for (const name of names.sort((a, b) => a.localeCompare(b))) {
agents.push({ name, role, isArchitect: role === 'architect' });
}
}
return agents;
}
function attachBusyTasks(cwd: string, agents: AgentView[]): AgentView[] {
const tasks = listTasks(cwd) as Array<{
id: string;
title: string;
status: TaskStatus;
assignedTo?: string;
createdAt: string;
updatedAt: string;
}>;
const busyByAgent = new Map<string, Task>();
for (const t of tasks) { for (const t of tasks) {
if (t.status === 'in_progress' && t.assignedTo) { if (t.status === 'in_progress' && t.assignedTo) {
const existing = busyByAgent.get(t.assignedTo); const existing = busyByAgent.get(t.assignedTo);
if (!existing || t.updatedAt > existing.updatedAt) { if (!existing || t.updatedAt > existing.updatedAt) busyByAgent.set(t.assignedTo, t);
busyByAgent.set(t.assignedTo, t as Task);
} }
} }
return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) }));
} }
return agents.map((a) => ({ function statusLine(a: RosterAgent): string {
...a, const busy = a.busyTask;
busyTask: busyByAgent.get(a.name), if (busy) {
})); return `<div style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
}
function renderAgentCard(agent: AgentView): string {
const busy = agent.busyTask;
const statusDot = busy
? `<span style="width:8px;height:8px;border-radius:50%;background:var(--status-in_progress);"></span> busy`
: `<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free`;
const busyLine = busy
? `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
${statusPill('in_progress')} ${statusPill('in_progress')}
<a href="/tasks/${escapeHtml(busy.id)}" style="color:var(--accent);text-decoration:none;font-family:var(--font-mono);font-size:12px;" data-live-timer data-timer-at="${escapeHtml(busy.updatedAt)}" data-timer-mode="in_progress">claimed moments ago</a> <a href="/tasks/${escapeHtml(busy.id)}" style="color:var(--accent);text-decoration:none;font-family:var(--font-mono);font-size:12px;" data-live-timer data-timer-at="${escapeHtml(busy.updatedAt)}" data-timer-mode="in_progress">claimed</a>
</div>`
: `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px;">
${statusDot}
</div>`; </div>`;
}
return ` return `<div style="margin-top:10px;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;">
<div class="agent-card" style=" <span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
min-width: 220px;
position: relative;
">
<div style="display:flex;align-items:center;gap:10px;">
${agentAvatar(agent.name, { architectRing: agent.isArchitect, size: 32 })}
<div style="min-width:0;">
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(agent.name)}</div>
<div style="color:var(--muted);font-size:11px;text-transform:capitalize;">${escapeHtml(agent.role)}</div>
</div>
</div>
${busyLine}
</div>`; </div>`;
} }
function renderRoleGroup(role: string, agents: AgentView[]): string { /**
const isArchitect = role === 'architect'; * Render one agent card. `avatar:'logo'` shows the provider brand mark (for
* standalone tiers); `avatar:'monogram'` shows the per-agent initials chip (used
* inside a provider group whose header already carries the brand logo).
*/
function agentCard(a: RosterAgent, opts: { avatar: 'logo' | 'monogram'; architectRing?: boolean } = { avatar: 'monogram' }): string {
const meta = providerMeta(a.kind);
const avatar =
opts.avatar === 'logo' && meta
? `<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:var(--raised);border:1px solid var(--border);${opts.architectRing ? `box-shadow:0 0 0 2px var(--bg),0 0 0 4px ${meta.color};` : ''}">${providerLogo(a.kind, 19)}</span>`
: agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 });
const model = a.model
? `<span style="font-family:var(--font-mono);font-size:11px;color:${meta ? meta.color : 'var(--muted)'};">${escapeHtml(a.model)}</span>`
: '';
return ` return `
<div class="role-group" data-role="${escapeHtml(role)}" style=" <div class="agent-card" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:212px;">
display: flex; <div style="display:flex;align-items:center;gap:10px;">
flex-direction: column; ${avatar}
align-items: center; <div style="min-width:0;">
gap: 14px; <div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(a.name)}</div>
position: relative; ${model}
">
<div class="role-label" style="
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
background: var(--bg);
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 999px;
z-index: 2;
">${escapeHtml(role)}</div>
<div class="agent-row" style="
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
position: relative;
${isArchitect ? '' : 'padding-top: 18px; border-top: 1px solid var(--border); margin-top: -11px; width: 100%;'}
">
${agents.map(renderAgentCard).join('')}
</div> </div>
</div>
${a.description ? `<div style="margin-top:8px;color:var(--muted);font-size:11px;line-height:1.4;">${escapeHtml(a.description)}</div>` : ''}
${statusLine(a)}
</div>`; </div>`;
} }
/** A provider column: brand-logo header + the agents running on that provider. */
function providerGroup(kind: string, agents: RosterAgent[]): string {
const meta = providerMeta(kind);
const busy = agents.filter((a) => a.busyTask).length;
return `
<div class="provider-group" style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;align-items:center;gap:8px;padding-bottom:4px;">
${providerLogo(kind, 18)}
<span style="font-weight:600;font-size:13px;">${meta ? escapeHtml(meta.name) : escapeHtml(kind)}</span>
<span style="flex:1;"></span>
<span style="color:var(--muted);font-size:11px;font-family:var(--font-mono);">${busy}/${agents.length} busy</span>
</div>
${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
</div>`;
}
function tierLabel(text: string): string {
return `<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);background:var(--bg);padding:2px 12px;border:1px solid var(--border);border-radius:999px;">${escapeHtml(text)}</div>`;
}
export function renderTeamHtml(cwd: string): string { export function renderTeamHtml(cwd: string): string {
const config = loadConfig(cwd); const config = loadConfig(cwd);
const agents = attachBusyTasks(cwd, gatherAgents(cwd)); const roster = attachBusy(cwd, gatherRoster(cwd));
const byRole = new Map<string, AgentView[]>(); const byRole = new Map<string, RosterAgent[]>();
for (const a of agents) { for (const a of roster) {
const list = byRole.get(a.role) ?? []; const list = byRole.get(a.role) ?? [];
list.push(a); list.push(a);
byRole.set(a.role, list); byRole.set(a.role, list);
} }
const orderedRoles = sortRoles(Array.from(byRole.keys())); const architects = (byRole.get('architect') ?? []).sort((a, b) => a.name.localeCompare(b.name));
const architectGroup = orderedRoles.includes('architect') const implementers = byRole.get('implementer') ?? [];
? renderRoleGroup('architect', byRole.get('architect')!) const testers = (byRole.get('tester') ?? []).sort((a, b) => a.name.localeCompare(b.name));
// Group implementers by provider, ordered anthropic -> openai -> moonshot -> rest.
const byProvider = new Map<string, RosterAgent[]>();
for (const a of implementers) {
const key = a.kind ?? 'other';
const list = byProvider.get(key) ?? [];
list.push(a);
byProvider.set(key, list);
}
const providerKeys = Array.from(byProvider.keys()).sort(
(a, b) => (PROVIDER_ORDER[a] ?? 99) - (PROVIDER_ORDER[b] ?? 99) || a.localeCompare(b),
);
for (const k of byProvider.keys()) {
byProvider.get(k)!.sort((a, b) => a.name.localeCompare(b.name));
}
const architectTier = architects.length
? `<div class="tier"><div class="tier-row">${architects.map((a) => agentCard(a, { avatar: 'logo', architectRing: true })).join('')}</div>${tierLabel('Architect')}</div>`
: ''; : '';
const workerGroups = orderedRoles
.filter((r) => r !== 'architect') const implementerTier = providerKeys.length
.map((r) => renderRoleGroup(r, byRole.get(r)!)) ? `<div class="tier">${tierLabel('Implementers')}<div class="provider-row">${providerKeys.map((k) => providerGroup(k, byProvider.get(k)!)).join('')}</div></div>`
.join(''); : '';
const testerTier = testers.length
? `<div class="tier">${tierLabel('Testers')}<div class="tier-row">${testers.map((a) => agentCard(a, { avatar: 'logo' })).join('')}</div></div>`
: '';
const connector = '<div class="connector-down" aria-hidden="true"></div>';
const tiers = [architectTier, implementerTier, testerTier].filter(Boolean).join(connector);
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
@ -205,51 +185,27 @@ export function renderTeamHtml(cwd: string): string {
<style> <style>
${designTokensCss()} ${designTokensCss()}
.tree { .tree { display:flex; flex-direction:column; align-items:center; gap:0; padding:8px 0 32px; }
display: flex; .tier { display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; }
flex-direction: column; .tier-row { display:flex; flex-wrap:wrap; justify-content:center; gap:14px; }
align-items: center; .provider-row { display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:18px; width:100%; }
gap: 36px; .provider-group { min-width:240px; max-width:300px; }
padding: 8px 0 32px; .connector-down { width:1px; height:28px; background:var(--border); flex:0 0 auto; }
}
.worker-tier { .agent-card { transition: border-color 150ms ease, transform 150ms ease; }
display: flex; .agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); }
flex-wrap: wrap;
justify-content: center;
gap: 36px;
width: 100%;
position: relative;
}
/* Connector line from architect tier down to worker tier */
.connector-down {
width: 1px;
height: 24px;
background: var(--border);
}
.agent-card {
transition: border-color 150ms ease, transform 150ms ease;
}
.agent-card:hover {
border-color: rgba(88, 166, 255, 0.5);
transform: translateY(-1px);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.worker-tier { flex-direction: column; align-items: center; gap: 24px; } .provider-row { flex-direction:column; align-items:center; gap:14px; }
.provider-group { max-width:none; width:100%; }
} }
</style> </style>
</head> </head>
<body> <body>
${pageHeader(config.projectName, 'team')} ${pageHeader(config.projectName, 'team')}
<main class="tree" id="tree"> <main class="tree" id="tree">
${architectGroup} ${tiers}
${workerGroups ? `<div class="connector-down" aria-hidden="true"></div><div class="worker-tier">${workerGroups}</div>` : ''}
</main> </main>
<script> <script>
${liveTimerJs()} ${liveTimerJs()}
</script> </script>

View File

@ -114,6 +114,42 @@ export function agentAvatar(
">${escapeHtml(spec.initial)}</span>`; ">${escapeHtml(spec.initial)}</span>`;
} }
/**
* Provider/company brand metadata + official Simple Icons SVG paths (verified,
* 24x24 viewBox). Used to group + badge agents by the model behind them.
*/
const PROVIDERS: Record<string, { name: string; color: string; path: string }> = {
anthropic: {
name: 'Anthropic',
color: '#D97757',
path: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
},
openai: {
name: 'OpenAI',
color: '#10A37F',
path: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
},
moonshot: {
name: 'Moonshot',
color: '#7C3AED',
path: 'm1.053 16.91 9.538 2.55a21 20.981 0 0 0 .06 2.031l5.956 1.592a12 11.99 0 0 1-15.554-6.172m-1.02-5.79 11.352 3.035a21 20.981 0 0 0-.469 2.01l10.817 2.89a12 11.99 0 0 1-1.845 2.004L.658 15.918a12 11.99 0 0 1-.625-4.796m1.593-5.146L13.573 9.17a21 20.981 0 0 0-1.01 1.874l11.297 3.02a21 20.981 0 0 1-.67 2.362l-11.55-3.087L.125 10.26a12 11.99 0 0 1 1.499-4.285ZM6.067 1.58l11.285 3.016a21 20.981 0 0 0-1.688 1.719l7.824 2.091a21 20.981 0 0 1 .513 2.664L2.107 5.218a12 11.99 0 0 1 3.96-3.638M21.68 4.866 7.222 1.003A12 11.99 0 0 1 21.68 4.866',
},
};
/** Brand metadata for a provider kind, or null if unknown. */
export function providerMeta(kind?: string): { name: string; color: string } | null {
if (!kind) return null;
const p = PROVIDERS[kind];
return p ? { name: p.name, color: p.color } : null;
}
/** Inline brand logo SVG for a provider kind, tinted with its brand color. */
export function providerLogo(kind: string | undefined, size = 20): string {
const p = kind ? PROVIDERS[kind] : undefined;
if (!p) return '';
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${p.color}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex:0 0 auto"><path d="${p.path}"/></svg>`;
}
/** Inline status pill for a task status. */ /** Inline status pill for a task status. */
export function statusPill(status: TaskStatus): string { export function statusPill(status: TaskStatus): string {
const labels: Record<TaskStatus, string> = { const labels: Record<TaskStatus, string> = {