@chatpanel/events 0.98.1 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +3 -0
- package/package.json +5 -1
- package/team-observe.js +118 -0
- package/team-org.js +311 -0
package/index.js
CHANGED
|
@@ -237,6 +237,9 @@ export { teamToolProvider, teamToolSpec, teamToolTimeoutMs, describeTeamForAppro
|
|
|
237
237
|
export { workLogFor, workLogText, workLogEvidence, describeCall, WORKLOG_KINDS } from './team-worklog.js';
|
|
238
238
|
export { normalizeRequest, subtaskFromRequest, takeUp, takeUpLine, holdsGrants, jobFromSubtask, extendDependents, taskTree, threadRows, MAX_SUBTASKS, MAX_DEPTH, MIN_TAKEUP_FIT } from './team-subtask.js';
|
|
239
239
|
export { teamLine, teamLanes } from './team-trail.js';
|
|
240
|
+
// The org, derived (F8 §17): roles as cards, starters whole, a team's health and shape, the roster, one colour per agent.
|
|
241
|
+
export { promoteRoles, starterTeam, missingStarters, teamHealth, teamShape, describeTeamShape, whereItWorks, rosterRows, agentKind, agentHue, agentColor, agentInitials, roleCardId, cardNumbers, upsertAgents, TEAM_SHAPES, ROSTER_KINDS } from './team-org.js';
|
|
242
|
+
export { observeInbox, observeStrip, runLanes, spendRows, engineStrip } from './team-observe.js';
|
|
240
243
|
export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
|
|
241
244
|
export { createManifest, ManifestError, SOURCES } from './manifest.js';
|
|
242
245
|
export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.100.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -114,6 +114,8 @@
|
|
|
114
114
|
"./team-trail.js": "./team-trail.js",
|
|
115
115
|
"./team-worklog.js": "./team-worklog.js",
|
|
116
116
|
"./team.js": "./team.js",
|
|
117
|
+
"./team-org.js": "./team-org.js",
|
|
118
|
+
"./team-observe.js": "./team-observe.js",
|
|
117
119
|
"./text-search.js": "./text-search.js",
|
|
118
120
|
"./theme.js": "./theme.js",
|
|
119
121
|
"./titles.js": "./titles.js",
|
|
@@ -253,6 +255,8 @@
|
|
|
253
255
|
"team-trail.js",
|
|
254
256
|
"team-worklog.js",
|
|
255
257
|
"team.js",
|
|
258
|
+
"team-org.js",
|
|
259
|
+
"team-observe.js",
|
|
256
260
|
"text-search.js",
|
|
257
261
|
"theme.js",
|
|
258
262
|
"titles.js",
|
package/team-observe.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Observe (F8 §17.2) — what is happening, where the time went, what needs you: derived from
|
|
2
|
+
// the run store's rows and records and the engine ledger's cards, once, here, so the desktop's
|
|
3
|
+
// Observe tab, the extension's and a phone's read the same inbox, the same lanes and the
|
|
4
|
+
// same spend rows. Nothing renders; the honesty rule holds — a number is given only where
|
|
5
|
+
// it is known (a relayed agent tool reports no tokens: its row says time, never $0).
|
|
6
|
+
|
|
7
|
+
import { runState, spendOf, LIVE_RUN_STATUSES } from './team-record.js';
|
|
8
|
+
|
|
9
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The inbox: every run that needs a person — an ask waiting (`waiting` or a `waiting` count
|
|
13
|
+
* on the row), a live run gone quiet (stalled) — newest first, each with the one action.
|
|
14
|
+
* `runs` are the store's list rows (`GET /v1/teams/runs`) or folded records.
|
|
15
|
+
*/
|
|
16
|
+
export function observeInbox(runs = [], { now = Date.now() } = {}) {
|
|
17
|
+
const items = [];
|
|
18
|
+
for (const r of Array.isArray(runs) ? runs : []) {
|
|
19
|
+
if (!r?.id) continue;
|
|
20
|
+
const st = runState(r, { now });
|
|
21
|
+
const waiting = r.status === 'waiting' || num(r.waiting) > 0 || st.key === 'waiting';
|
|
22
|
+
if (waiting) items.push({ kind: 'ask', runId: r.id, team: r.team || '', request: String(r.request || '').slice(0, 120), count: Math.max(1, num(r.waiting)), action: 'answer', at: num(r.lastEventAt) || num(r.createdAt), detail: st.detail || '' });
|
|
23
|
+
else if (st.key === 'stalled') items.push({ kind: 'stalled', runId: r.id, team: r.team || '', request: String(r.request || '').slice(0, 120), action: 'resume', at: num(r.lastEventAt) || num(r.createdAt), detail: st.detail || '' });
|
|
24
|
+
}
|
|
25
|
+
return items.sort((a, b) => (a.kind === b.kind ? b.at - a.at : a.kind === 'ask' ? -1 : 1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The strip's numbers: live runs, stalled, asks waiting, tasks done this window, rotations seen. */
|
|
29
|
+
export function observeStrip(runs = [], { now = Date.now(), sinceMs = 7 * 24 * 3600 * 1000 } = {}) {
|
|
30
|
+
const list = (Array.isArray(runs) ? runs : []).filter((r) => r?.id && num(r.createdAt) >= now - sinceMs);
|
|
31
|
+
const live = list.filter((r) => LIVE_RUN_STATUSES.includes(r.status));
|
|
32
|
+
const stalled = live.filter((r) => runState(r, { now }).key === 'stalled').length;
|
|
33
|
+
const asks = observeInbox(list, { now }).filter((i) => i.kind === 'ask').reduce((n, i) => n + i.count, 0);
|
|
34
|
+
const tasks = list.reduce((a, r) => { for (const t of r.tasks || []) { if (t.status === 'ok') a.done += 1; else if (t.status === 'failed' || t.status === 'unassigned') a.failed += 1; } return a; }, { done: 0, failed: 0 });
|
|
35
|
+
const spend = list.reduce((a, r) => { const s = r.usage?.spent || {}; a.usd += num(s.usd); a.ms += num(s.ms); a.tokens += num(s.tokens); return a; }, { usd: 0, ms: 0, tokens: 0 });
|
|
36
|
+
return { runs: list.length, live: live.length, stalled, asks, tasks, spend, completed: list.filter((r) => r.status === 'completed').length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One run's timeline: a lane per task with segments placed on the run's own clock (0..1 of
|
|
41
|
+
* `span`), so a client draws bars without knowing the record. Segments: `working` (started →
|
|
42
|
+
* ended or now), `waiting` (an ask), `declined` (an attempt that failed and rotated),
|
|
43
|
+
* `earlier` (a task that ended before the last start of a live run, drawn grey). A sub-task
|
|
44
|
+
* carries `parent` for nesting.
|
|
45
|
+
*/
|
|
46
|
+
export function runLanes(run, { now = Date.now() } = {}) {
|
|
47
|
+
const has = (v) => Number.isFinite(Number(v)) && v !== null && v !== undefined && v !== '';
|
|
48
|
+
const start = has(run?.startedAt) ? Number(run.startedAt) : has(run?.createdAt) ? Number(run.createdAt) : now;
|
|
49
|
+
const live = LIVE_RUN_STATUSES.includes(run?.status);
|
|
50
|
+
const end = live ? now : Math.max(start + 1, num(run?.endedAt) || num(run?.lastEventAt) || now);
|
|
51
|
+
const span = Math.max(1, end - start);
|
|
52
|
+
const at = (t) => Math.max(0, Math.min(1, (t - start) / span));
|
|
53
|
+
const lanes = [];
|
|
54
|
+
for (const t of run?.tasks || []) {
|
|
55
|
+
const segs = [];
|
|
56
|
+
const s0 = has(t.startedAt) ? Number(t.startedAt) : null;
|
|
57
|
+
const e0 = has(t.endedAt) ? Number(t.endedAt) : (t.status === 'running' || t.status === 'waiting' ? end : null);
|
|
58
|
+
for (const a of t.attempts || []) {
|
|
59
|
+
if (!has(a.startedAt) || !has(a.endedAt) || !(a.status === 'failed' || a.rotated)) continue;
|
|
60
|
+
const as = Number(a.startedAt), ae = Number(a.endedAt);
|
|
61
|
+
segs.push({ kind: 'declined', from: at(as), to: at(Math.max(ae, as + span / 200)), label: a.error || a.model || 'declined' });
|
|
62
|
+
}
|
|
63
|
+
if (s0 !== null && e0 !== null) segs.push({ kind: t.status === 'waiting' ? 'waiting' : 'working', from: at(s0), to: at(Math.max(e0, s0 + span / 200)), label: t.status });
|
|
64
|
+
const ms = s0 !== null ? (e0 ?? end) - s0 : num(t.ms);
|
|
65
|
+
lanes.push({ id: t.id, role: t.role || null, title: t.title || t.id, status: t.status, parent: t.parent || null, kind: t.kind || 'task', findings: num(t.findings), ms, segments: segs });
|
|
66
|
+
}
|
|
67
|
+
return { start, end, span, live, lanes };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Spend by agent (the role's card id when the role names one, else the role), by team, or
|
|
72
|
+
* by engine — from the run rows' `usage.spent` and each task's share where the record has
|
|
73
|
+
* it. `usd` is `null`, not 0, when no run priced it; `ms` is always known.
|
|
74
|
+
*/
|
|
75
|
+
export function spendRows(runs = [], { by = 'team', now = Date.now() } = {}) {
|
|
76
|
+
const rows = new Map();
|
|
77
|
+
const add = (key, label, spent, { priced }) => {
|
|
78
|
+
const r = rows.get(key) || { key, label, ms: 0, tokens: 0, usd: null, calls: 0, runs: 0 };
|
|
79
|
+
r.ms += num(spent.ms); r.tokens += num(spent.tokens); r.calls += num(spent.calls); r.runs += 1;
|
|
80
|
+
if (priced) r.usd = num(r.usd) + num(spent.usd);
|
|
81
|
+
rows.set(key, r);
|
|
82
|
+
};
|
|
83
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
84
|
+
if (!run?.id) continue;
|
|
85
|
+
const s = spendOf(run, { now })?.spent || run.usage?.spent || {};
|
|
86
|
+
const priced = num(s.usd) > 0 || num(s.tokens) > 0;
|
|
87
|
+
if (by === 'team') add(run.team || '?', run.team || '?', s, { priced });
|
|
88
|
+
else if (by === 'agent') {
|
|
89
|
+
const tasks = (run.tasks || []).filter((t) => t.role);
|
|
90
|
+
const share = tasks.length ? 1 / tasks.length : 0;
|
|
91
|
+
for (const t of tasks) {
|
|
92
|
+
const role = (run.roles || []).find((r) => r.id === t.role);
|
|
93
|
+
const key = role?.agent || `${run.team || '?'}-${t.role}`;
|
|
94
|
+
add(key, role?.agent || t.role, { ms: num(t.ms) || num(s.ms) * share, tokens: num(s.tokens) * share, usd: num(s.usd) * share, calls: num(s.calls) * share }, { priced });
|
|
95
|
+
}
|
|
96
|
+
} else if (by === 'engine') {
|
|
97
|
+
for (const t of run.tasks || []) { const k = t.engine ? `${t.engine.kind}:${t.engine.id}${t.engine.model ? `/${t.engine.model}` : ''}` : (t.model || '?'); add(k, k, { ms: num(t.ms), tokens: 0, usd: 0, calls: 0 }, { priced: false }); }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const out = [...rows.values()].sort((a, b) => (b.usd ?? 0) - (a.usd ?? 0) || b.ms - a.ms);
|
|
101
|
+
const max = Math.max(1, ...out.map((r) => (r.usd ?? 0) || 0), ...out.map((r) => r.ms / 60000));
|
|
102
|
+
return out.map((r) => ({ ...r, share: r.usd != null && r.usd > 0 ? r.usd / max : (r.ms / 60000) / max }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An engine card as one strip: 24 hour cells (`ok` · `declined` · `none`), the rate, and whether it is declining now. */
|
|
106
|
+
export function engineStrip(card) {
|
|
107
|
+
const a = card?.availability || {};
|
|
108
|
+
const hours = Array.isArray(a.byHour) ? a.byHour : Array.from({ length: 24 }, () => ({ calls: 0, declines: 0 }));
|
|
109
|
+
return {
|
|
110
|
+
key: card?.key || '?',
|
|
111
|
+
label: card?.engine ? `${card.engine.id}${card.engine.model ? ` · ${card.engine.model}` : ''}` : (card?.key || '?'),
|
|
112
|
+
rate: a.rate ?? null,
|
|
113
|
+
decliningNow: !!a.decliningNow,
|
|
114
|
+
p50: card?.latency?.total?.p50 ?? null,
|
|
115
|
+
calls: num(card?.calls),
|
|
116
|
+
cells: hours.map((h) => (num(h.declines) > 0 && num(h.declines) >= num(h.calls) ? 'declined' : num(h.calls) > 0 ? 'ok' : 'none')),
|
|
117
|
+
};
|
|
118
|
+
}
|
package/team-org.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// The org, derived — what the Agent Teams surface draws (F8 §17), computed once here so the
|
|
2
|
+
// desktop, the extension and a phone render the same roster, the same shape and the same
|
|
3
|
+
// colours from the same two sections (`agents`, `teams`) and the same records.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: the Agents tab and the Teams tab disagreed. `research` and `review` kept
|
|
6
|
+
// their roles INLINE — prompt, tier and grants written into the team — so the roles ran, were
|
|
7
|
+
// scored, and were never in the pool; `feature` and friends referenced `agent: architect` by
|
|
8
|
+
// id, which existed only after "+ the engineering org" was clicked, so a team could be saved
|
|
9
|
+
// full of holes and the only place that was said was a `<select>` option. Both are answered
|
|
10
|
+
// by the same rule: EVERY ROLE THAT CAN RUN IS A CARD IN THE POOL, a hole is a state a client
|
|
11
|
+
// draws, and a starter team brings the agents it stands on.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here renders. A client maps `columns` to boxes and arrows, `hue` to a colour, and
|
|
14
|
+
// `kind` to a word; the SVG is its own.
|
|
15
|
+
|
|
16
|
+
import { normalizeTeam, validateTeam, starterTeams, TeamError } from './team.js';
|
|
17
|
+
import { normalizeAgent, engineOf, starterAgents, STARTER_AGENTS, ASSISTANT_ID } from './agent.js';
|
|
18
|
+
|
|
19
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
20
|
+
const poolList = (pool) => (Array.isArray(pool) ? pool : []).filter((a) => a && a.id);
|
|
21
|
+
|
|
22
|
+
/** The card a team's inline role becomes: `<team>-<role>`, an id the pool and the gateway's routes accept. */
|
|
23
|
+
export function roleCardId(teamName, roleId) {
|
|
24
|
+
return `${String(teamName || '').toLowerCase()}-${String(roleId || '').toLowerCase()}`.replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').slice(0, 64);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const titleCase = (id) => String(id || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).slice(0, 60);
|
|
28
|
+
const firstSentence = (text) => String(text || '').trim().split(/(?<=[.!?])\s+/)[0]?.slice(0, 300) || '';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A team as it is SAVED: every inline `model` role becomes a pool card (`agents` to upsert —
|
|
32
|
+
* the role's prompt, grants, engine, skills and working directory move onto it, stamped
|
|
33
|
+
* `createdBy: team:<name>` and `origin: { team, role }`) and the role is rewritten to
|
|
34
|
+
* `agent: <card id>` keeping only what is the team's — its id, name, dependencies. A role
|
|
35
|
+
* that already names an agent, a `recipe` or `subagent` role, is left as it is.
|
|
36
|
+
*
|
|
37
|
+
* Idempotent: saving the same team again yields the same cards (a card's `createdAt` is kept
|
|
38
|
+
* from the pool). The caller writes BOTH sections; this is what both clients' save paths
|
|
39
|
+
* — the form and the chat card — call, so a role can never again run without being seen.
|
|
40
|
+
*/
|
|
41
|
+
export function promoteRoles(team, pool = [], { now = Date.now } = {}) {
|
|
42
|
+
const t = normalizeTeam(team);
|
|
43
|
+
const byId = new Map(poolList(pool).map((a) => [String(a.id), a]));
|
|
44
|
+
const agents = [];
|
|
45
|
+
const roles = t.roles.map((r) => {
|
|
46
|
+
if (r.agent || r.mode !== 'model') return r;
|
|
47
|
+
const id = roleCardId(t.name, r.id);
|
|
48
|
+
const existing = byId.get(id);
|
|
49
|
+
const card = normalizeAgent({
|
|
50
|
+
id,
|
|
51
|
+
name: r.name && r.name !== r.id ? r.name : titleCase(r.id),
|
|
52
|
+
purpose: firstSentence(r.prompt),
|
|
53
|
+
prompt: r.prompt || `You are the ${r.id} of the ${t.name} team.`,
|
|
54
|
+
skills: r.skills || [],
|
|
55
|
+
grants: r.grants || ['none'],
|
|
56
|
+
engine: r.engine || engineOf(r),
|
|
57
|
+
...(r.workdir ? { workdir: r.workdir } : {}),
|
|
58
|
+
...(r.egress ? { egress: r.egress } : {}),
|
|
59
|
+
...(r.memoryScope ? { memoryScope: r.memoryScope } : {}),
|
|
60
|
+
appliesTo: ['jobs'],
|
|
61
|
+
createdBy: `team:${t.name}`,
|
|
62
|
+
origin: { team: t.name, role: r.id },
|
|
63
|
+
createdAt: existing?.createdAt || now(),
|
|
64
|
+
enabled: true,
|
|
65
|
+
});
|
|
66
|
+
agents.push(card);
|
|
67
|
+
return {
|
|
68
|
+
id: r.id,
|
|
69
|
+
...(r.name && r.name !== r.id ? { name: r.name } : {}),
|
|
70
|
+
mode: r.mode,
|
|
71
|
+
agent: id,
|
|
72
|
+
...(r.dependsOn?.length ? { dependsOn: [...r.dependsOn] } : {}),
|
|
73
|
+
...(r.model ? { model: r.model } : {}),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
return { team: normalizeTeam({ ...t, roles }), agents };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A starter team WITH the agents it stands on: the inline roles promoted, plus every
|
|
81
|
+
* built-in agent it references that the pool lacks. "+ research starter" adds two cards
|
|
82
|
+
* and a team; "+ feature starter" adds the team and the Architect, Implementer, Reviewer,
|
|
83
|
+
* Tester and Scribe if they are not there yet. Never half-installed.
|
|
84
|
+
*/
|
|
85
|
+
export function starterTeam(name, pool = [], opts = {}) {
|
|
86
|
+
const src = starterTeams().find((t) => t.name === name);
|
|
87
|
+
if (!src) return null;
|
|
88
|
+
const { team, agents } = promoteRoles(src, pool, opts);
|
|
89
|
+
const have = new Set([...poolList(pool).map((a) => String(a.id)), ...agents.map((a) => a.id)]);
|
|
90
|
+
const builtins = starterAgents().filter((a) => team.roles.some((r) => r.agent === a.id) && !have.has(a.id));
|
|
91
|
+
return { team, agents: [...agents, ...builtins] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Which built-in agents a team names that the pool lacks — what "Add the built-in Tester" adds. */
|
|
95
|
+
export function missingStarters(team, pool = []) {
|
|
96
|
+
const have = new Set(poolList(pool).map((a) => String(a.id)));
|
|
97
|
+
return starterAgents().filter((a) => (team?.roles || []).some((r) => r.agent === a.id) && !have.has(a.id));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Can this team run as it stands? A hole is a role naming an agent that is not in the pool
|
|
102
|
+
* (`fix: 'add-builtin'` when a starter has that id, else `'pick'`); a disabled agent is its
|
|
103
|
+
* own row. `resolveTeam` still throws NO_AGENT beneath — this is the state a client draws
|
|
104
|
+
* BEFORE the run button, with the reason in one line.
|
|
105
|
+
*/
|
|
106
|
+
export function teamHealth(team, pool = []) {
|
|
107
|
+
const byId = new Map(poolList(pool).map((a) => [String(a.id), a]));
|
|
108
|
+
const holes = [];
|
|
109
|
+
const disabled = [];
|
|
110
|
+
const valid = validateTeam(team).ok;
|
|
111
|
+
for (const r of team?.roles || []) {
|
|
112
|
+
if (!r?.agent || r.agent === ASSISTANT_ID) continue;
|
|
113
|
+
const a = byId.get(String(r.agent));
|
|
114
|
+
if (!a) holes.push({ role: r.id, agent: r.agent, fix: STARTER_AGENTS.some((s) => s.id === r.agent) ? 'add-builtin' : 'pick' });
|
|
115
|
+
else if (a.enabled === false) disabled.push({ role: r.id, agent: r.agent });
|
|
116
|
+
}
|
|
117
|
+
// Roles written into the team before §17.1 (or by an older client): they run, they are
|
|
118
|
+
// scored, and they are not on the roster. Not a hole — a save promotes them.
|
|
119
|
+
const inline = (team?.roles || []).filter((r) => r && !r.agent && (r.mode || 'model') === 'model').map((r) => r.id);
|
|
120
|
+
const off = team?.enabled === false;
|
|
121
|
+
const ready = valid && !off && !holes.length && !disabled.length;
|
|
122
|
+
const reason = !valid ? 'the team is not complete'
|
|
123
|
+
: off ? 'the team is off'
|
|
124
|
+
: holes.length ? `${holes.length === 1 ? 'a role names an agent' : `${holes.length} roles name agents`} not in the pool: ${holes.map((h) => h.agent).join(', ')}`
|
|
125
|
+
: disabled.length ? `${disabled.map((d) => d.agent).join(', ')} ${disabled.length === 1 ? 'is' : 'are'} off`
|
|
126
|
+
: '';
|
|
127
|
+
return { ready, valid, holes, disabled, inline, reason };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A role's depth: 0 with no dependencies, else one past the deepest; a cycle or an unknown dependency counts as 0. */
|
|
131
|
+
function depths(roles) {
|
|
132
|
+
const byId = new Map(roles.map((r) => [r.id, r]));
|
|
133
|
+
const memo = new Map();
|
|
134
|
+
const depth = (id, seen) => {
|
|
135
|
+
if (memo.has(id)) return memo.get(id);
|
|
136
|
+
if (seen.has(id)) return 0;
|
|
137
|
+
const r = byId.get(id);
|
|
138
|
+
const deps = (r?.dependsOn || []).filter((d) => byId.has(d));
|
|
139
|
+
const d = deps.length ? 1 + Math.max(...deps.map((x) => depth(x, new Set([...seen, id])))) : 0;
|
|
140
|
+
memo.set(id, d);
|
|
141
|
+
return d;
|
|
142
|
+
};
|
|
143
|
+
for (const r of roles) depth(r.id, new Set());
|
|
144
|
+
return memo;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const TEAM_SHAPES = Object.freeze(['solo', 'quorum', 'sequence', 'hierarchy']);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The shape of a team, from its roles — the one drawing both clients make:
|
|
151
|
+
* • `columns` — roles grouped by dependency depth; one column runs in parallel, the next
|
|
152
|
+
* waits for it. The judge (merge: judge) is not in them: the merge IS its task, so it
|
|
153
|
+
* stands as the last column on its own (`judge`).
|
|
154
|
+
* • `kind` — `solo` (one role), `quorum` (one parallel column, merged), `sequence` (more
|
|
155
|
+
* than one column), `hierarchy` (a role whose engine is another team — A2, not built;
|
|
156
|
+
* read from `mode: 'team'` so the word is ready when the mode is).
|
|
157
|
+
* • `lands: 'person'` — always. Nothing lands without one; the drawing ends on you.
|
|
158
|
+
* Node fields are the team's own (`id`, `agent`, `name`, `dependsOn`); a client joins the
|
|
159
|
+
* pool for the card and `agentHue` for the colour.
|
|
160
|
+
*/
|
|
161
|
+
export function teamShape(team) {
|
|
162
|
+
const roles = (team?.roles || []).filter((r) => r && r.id);
|
|
163
|
+
const judgeId = team?.merge === 'judge' ? (team.judge || roles[roles.length - 1]?.id || null) : null;
|
|
164
|
+
const working = roles.filter((r) => r.id !== judgeId);
|
|
165
|
+
const d = depths(working);
|
|
166
|
+
const byDepth = new Map();
|
|
167
|
+
for (const r of working) {
|
|
168
|
+
const k = d.get(r.id) || 0;
|
|
169
|
+
if (!byDepth.has(k)) byDepth.set(k, []);
|
|
170
|
+
byDepth.get(k).push({ id: r.id, name: r.name || r.id, agent: r.agent || null, mode: r.mode || 'model', dependsOn: [...(r.dependsOn || [])] });
|
|
171
|
+
}
|
|
172
|
+
const columns = [...byDepth.keys()].sort((a, b) => a - b).map((depth) => ({ depth, parallel: byDepth.get(depth).length > 1, roles: byDepth.get(depth) }));
|
|
173
|
+
const j = roles.find((r) => r.id === judgeId);
|
|
174
|
+
const judge = j ? { id: j.id, name: j.name || j.id, agent: j.agent || null } : null;
|
|
175
|
+
const kind = roles.some((r) => r.mode === 'team') ? 'hierarchy'
|
|
176
|
+
: roles.length <= 1 ? 'solo'
|
|
177
|
+
: columns.length <= 1 ? 'quorum'
|
|
178
|
+
: 'sequence';
|
|
179
|
+
return { kind, columns, judge, merge: team?.merge || 'concat', lands: 'person' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A shape in a sentence — the card's subtitle, the same on every client. */
|
|
183
|
+
export function describeTeamShape(shape) {
|
|
184
|
+
const s = shape || {};
|
|
185
|
+
const n = (s.columns || []).reduce((a, c) => a + c.roles.length, 0) + (s.judge ? 1 : 0);
|
|
186
|
+
if (s.kind === 'solo') return 'one role';
|
|
187
|
+
if (s.kind === 'quorum') return `${n} roles in parallel${s.judge ? `, ${s.judge.name} judges` : s.merge === 'converge' ? ', reconciled' : ''}`;
|
|
188
|
+
if (s.kind === 'hierarchy') return `${n} roles, one delegates to a team`;
|
|
189
|
+
return `${n} roles in ${s.columns.length} steps${s.judge ? `, ${s.judge.name} judges` : ''}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Where an agent works: the teams whose roles name it and the project jobs it was recruited
|
|
194
|
+
* for or holds. `projects` are project records (`foldProject`); a job's `recruited.agentId`
|
|
195
|
+
* or `takenBy.agentId` is the link.
|
|
196
|
+
*/
|
|
197
|
+
export function whereItWorks(agentId, { teams = [], projects = [] } = {}) {
|
|
198
|
+
const id = String(agentId || '');
|
|
199
|
+
const onTeams = (Array.isArray(teams) ? teams : []).filter((t) => t && (t.roles || []).some((r) => r?.agent === id)).map((t) => ({ team: t.name, role: (t.roles || []).find((r) => r?.agent === id)?.id || null }));
|
|
200
|
+
const jobs = [];
|
|
201
|
+
for (const p of Array.isArray(projects) ? projects : []) {
|
|
202
|
+
for (const j of p?.jobs || []) {
|
|
203
|
+
const who = j?.recruited?.agentId || j?.takenBy?.agentId || null;
|
|
204
|
+
if (who === id) jobs.push({ project: p.id, title: p.page?.title || p.id, job: j.id, status: j.status || 'open' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { teams: onTeams, jobs };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const ROSTER_KINDS = Object.freeze(['builtin', 'mine', 'team-role', 'created', 'proposed', 'missing']);
|
|
211
|
+
|
|
212
|
+
/** What kind of card this is, from how it came to be — never declared by the card. */
|
|
213
|
+
export function agentKind(agent) {
|
|
214
|
+
const a = agent || {};
|
|
215
|
+
if (a.id === ASSISTANT_ID || a.builtin || STARTER_AGENTS.some((s) => s.id === a.id)) return 'builtin';
|
|
216
|
+
const by = String(a.createdBy || 'person');
|
|
217
|
+
if (by.startsWith('team:')) return 'team-role';
|
|
218
|
+
if (by !== 'person') return 'created';
|
|
219
|
+
return 'mine';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The roster — one row per thing the Agents tab shows: every pool card with its kind and
|
|
224
|
+
* where it works; every PROPOSED card (from a run's `proposal` decisions or a project's —
|
|
225
|
+
* pass them as `proposals: [{ agent, from }]`), and every MISSING agent a team names,
|
|
226
|
+
* deduplicated, with the fix. Sorted: what needs a decision first, then holes, then the
|
|
227
|
+
* pool by name. `counts` is the filter bar.
|
|
228
|
+
*/
|
|
229
|
+
export function rosterRows(pool = [], { teams = [], projects = [], proposals = [] } = {}) {
|
|
230
|
+
const rows = [];
|
|
231
|
+
for (const a of poolList(pool)) {
|
|
232
|
+
rows.push({ kind: agentKind(a), agent: a, where: whereItWorks(a.id, { teams, projects }), hue: agentHue(a.id) });
|
|
233
|
+
}
|
|
234
|
+
const seen = new Set(rows.map((r) => r.agent.id));
|
|
235
|
+
for (const p of Array.isArray(proposals) ? proposals : []) {
|
|
236
|
+
const a = p?.agent && isRecord(p.agent) ? p.agent : null;
|
|
237
|
+
if (!a?.id || seen.has(a.id)) continue;
|
|
238
|
+
seen.add(a.id);
|
|
239
|
+
rows.push({ kind: 'proposed', agent: a, from: p.from || null, where: { teams: [], jobs: [] }, hue: agentHue(a.id) });
|
|
240
|
+
}
|
|
241
|
+
for (const t of Array.isArray(teams) ? teams : []) {
|
|
242
|
+
for (const h of teamHealth(t, pool).holes) {
|
|
243
|
+
if (seen.has(h.agent)) { rows.find((r) => r.agent.id === h.agent)?.namedBy?.push(t.name); continue; }
|
|
244
|
+
seen.add(h.agent);
|
|
245
|
+
rows.push({ kind: 'missing', agent: { id: h.agent, name: h.agent }, fix: h.fix, namedBy: [t.name], where: { teams: [{ team: t.name, role: h.role }], jobs: [] }, hue: agentHue(h.agent) });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const order = { proposed: 0, missing: 1, builtin: 3, 'team-role': 3, created: 3, mine: 3 };
|
|
249
|
+
rows.sort((a, b) => (order[a.kind] - order[b.kind]) || String(a.agent.name || a.agent.id).localeCompare(String(b.agent.name || b.agent.id)));
|
|
250
|
+
const counts = { all: rows.length };
|
|
251
|
+
for (const k of ROSTER_KINDS) counts[k] = rows.filter((r) => r.kind === k).length;
|
|
252
|
+
counts.onTeam = rows.filter((r) => r.where.teams.length).length;
|
|
253
|
+
return { rows, counts };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* An agent's colour is data: one hue per id, the same on every client and every tab, so a
|
|
258
|
+
* team's shape reads without labels. FNV-1a over the id → 0..359; the built-in org keeps
|
|
259
|
+
* fixed, well-separated hues so the seven starters never land next to each other.
|
|
260
|
+
*/
|
|
261
|
+
const FIXED_HUES = Object.freeze({ assistant: 240, executive: 262, architect: 212, implementer: 158, reviewer: 38, tester: 330, librarian: 190, scribe: 0, release: 280 });
|
|
262
|
+
export function agentHue(id) {
|
|
263
|
+
const key = String(id || '');
|
|
264
|
+
if (FIXED_HUES[key] !== undefined) return FIXED_HUES[key];
|
|
265
|
+
let h = 0x811c9dc5;
|
|
266
|
+
for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
267
|
+
return h % 360;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The CSS colour for a hue — muted on a light ground, lifted on a dark one; the scribe's 0 is a grey, not a red. */
|
|
271
|
+
export function agentColor(id, { dark = false } = {}) {
|
|
272
|
+
const h = agentHue(id);
|
|
273
|
+
if (id === 'scribe') return dark ? 'hsl(220 8% 62%)' : 'hsl(220 8% 45%)';
|
|
274
|
+
return dark ? `hsl(${h} 58% 62%)` : `hsl(${h} 60% 44%)`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Two letters for the avatar: "Budget checker" → "Bc", "researcher" → "Re". */
|
|
278
|
+
export function agentInitials(agentOrId) {
|
|
279
|
+
const name = typeof agentOrId === 'string' ? agentOrId : (agentOrId?.name || agentOrId?.id || '');
|
|
280
|
+
const words = String(name).trim().split(/[\s_-]+/).filter(Boolean);
|
|
281
|
+
if (!words.length) return '?';
|
|
282
|
+
if (words.length === 1) return (words[0][0].toUpperCase() + (words[0][1] || '')).slice(0, 2);
|
|
283
|
+
return (words[0][0] + words[1][0].toLowerCase()).slice(0, 2).replace(/^./, (c) => c.toUpperCase());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export { TeamError };
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The four numbers on an agent's card, from the gateway's scorecard (`summarize()` +
|
|
290
|
+
* `attested`) — the same four on every client, "—" where nothing is known. A run through an
|
|
291
|
+
* agent tool reports no tokens, so cost is never one of them; time and count always are.
|
|
292
|
+
*/
|
|
293
|
+
export function cardNumbers(summary, { attested = null } = {}) {
|
|
294
|
+
const s = summary && typeof summary === 'object' ? summary : null;
|
|
295
|
+
const n = s?.entries || 0;
|
|
296
|
+
const pct = (v) => (Number.isFinite(v) ? `${Math.round(v * 100)}%` : '—');
|
|
297
|
+
const tasks = (s?.jobsDone || 0) + (s?.jobsFailed || 0);
|
|
298
|
+
return [
|
|
299
|
+
{ key: 'tasks', label: 'tasks', value: n ? String(tasks) : '—', detail: n ? `${s.jobsDone} done · ${s.jobsFailed} failed` : 'nothing yet' },
|
|
300
|
+
{ key: 'rating', label: 'rating', value: n ? pct(s.rating?.avg) : '—', detail: n && s.rating?.count ? `${s.rating.count} rating${s.rating.count === 1 ? '' : 's'}` : 'not rated' },
|
|
301
|
+
{ key: 'engines', label: 'engines', value: n ? String((s.byEngine || []).length) : '—', detail: s?.engineIndependence != null ? `independence ${pct(s.engineIndependence)}` : 'one so far' },
|
|
302
|
+
{ key: 'record', label: 'record', value: n ? (attested?.ok ? '✓' : n ? '·' : '—') : '—', detail: n ? `${n} entr${n === 1 ? 'y' : 'ies'}${attested?.ok ? ', attested' : ''}${s.scm?.commits ? ` · ${s.scm.commits} commits` : ''}` : 'nothing yet' },
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Cards onto the pool: an existing id is replaced in place, a new one appended — the order a person made stays. */
|
|
307
|
+
export function upsertAgents(pool, cards) {
|
|
308
|
+
const list = poolList(pool);
|
|
309
|
+
const add = (Array.isArray(cards) ? cards : []).filter((c) => c && c.id);
|
|
310
|
+
return [...list.map((a) => add.find((c) => c.id === a.id) || a), ...add.filter((c) => !list.some((a) => a.id === c.id))];
|
|
311
|
+
}
|