@chatpanel/events 0.85.0 → 0.88.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/team-run.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // says so (`status: 'over-budget'`). Stop is one signal, fanned out.
18
18
 
19
19
  import { normalizeTeam } from './team.js';
20
+ import { normalizeEngine, normalizeScm } from './scorecard.js';
20
21
  import { createBudget } from './budget.js';
21
22
  import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
22
23
  import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
@@ -79,9 +80,15 @@ export function dryRunTeam(team, request, { appoint = null } = {}) {
79
80
 
80
81
  /**
81
82
  * @param callModel `async ({ runId, taskId, role, model, mode, system, prompt, tools, signal, onDelta }) =>
82
- * { ok, text, usage?, error?, aborted? }` — the host's model turn
83
+ * { ok, text, usage?, error?, aborted?, scm? }` — the host's model turn; `scm`
84
+ * is what a harness did in a git checkout (`{ repo, branch, head, headAfter,
85
+ * commits }`), when the bridge reported one
83
86
  * @param toolsFor `(role) => toolset | undefined` — narrowed to the role's grants by the host
84
- * @param appoint `(role) => { model, mode } | null` — the host's roster through cowriter-router
87
+ * @param appoint `(role) => { model, mode, engine?, reasons?, alternatives? } | null` — the host's
88
+ * roster through cowriter-router. `engine` (`{ kind: 'model'|'harness', id,
89
+ * model? }`) says WHAT the model id is, so the record can split by it;
90
+ * `reasons` and `alternatives` are why this one and who else could have —
91
+ * said as `task.routed`, which every run records from here on (pillars §13)
85
92
  * @param runRecipe `async (name, params) => result` for `mode: 'recipe'` roles (optional)
86
93
  * @param emit `(type, payload)` — run.started · plan.ready · task.started · task.finding ·
87
94
  * task.done · task.failed · run.merging · run.done; the host forwards them to
@@ -129,6 +136,21 @@ export async function runTeam({
129
136
  return (appoint ? appoint(r, { exclude }) : null) || (r.model && !exclude?.has(r.model) ? { model: r.model, mode: r.mode } : null);
130
137
  };
131
138
  const MAX_APPOINTMENTS = 3;
139
+ // The routing decision, on the record: which engine, why, who else could have. A host that
140
+ // does not say the kind gets `model` — the honest default for a bare id; the pilot's hosts
141
+ // both say. Exploration (a tier cheaper on purpose) is the project loop's, later; false here.
142
+ const routeOf = (m, role, { attempt = 1, exclude = null, handoff = null } = {}) => {
143
+ const reasons = Array.isArray(m.reasons) ? m.reasons.map(String) : [];
144
+ if (handoff) reasons.unshift(`handed off by ${handoff.by}${handoff.reason ? ` — ${handoff.reason}` : ''}`);
145
+ else if (!reasons.length && role.model && m.model === role.model) reasons.push('pinned by the role');
146
+ if (attempt > 1 && exclude?.size) reasons.push(`after ${[...exclude].join(', ')} (unavailable)`);
147
+ return {
148
+ engine: normalizeEngine(m.engine || { id: m.model }),
149
+ reasons,
150
+ alternatives: (Array.isArray(m.alternatives) ? m.alternatives : []).slice(0, 5).map((a) => normalizeEngine(a)).filter(Boolean),
151
+ exploration: false,
152
+ };
153
+ };
132
154
  // A model that was not there for one member is not there for the next: what failed as
133
155
  // unavailable anywhere in this run is skipped by every later appointment. Two members
134
156
  // each spent two minutes finding out the same agent was down.
@@ -199,6 +221,8 @@ export async function runTeam({
199
221
  // the record. Only a task that has never been attempted starts from the bare prompt.
200
222
  let transcript = was ? [...was.transcript] : [];
201
223
  const attempts = was?.attempts ? [...was.attempts] : [];
224
+ let routed = null; // the last routing decision, on the task row
225
+ let scm = null; // what the last attempt did in a checkout
202
226
  // The task's own abort: an ask nobody answered in time stops THIS member's turn (the
203
227
  // run then checkpoints), without stopping the run's other members. A person's hand-off
204
228
  // aborts it too, and names where the task continues.
@@ -250,6 +274,7 @@ export async function runTeam({
250
274
  let lastModel = was?.attempts?.at?.(-1)?.model || null;
251
275
  for (let attempt = 1; ; attempt++) {
252
276
  let m;
277
+ const handoffNow = handoffTo;
253
278
  if (handoffTo) {
254
279
  // A person's hand-off names the model; the task continues there whatever the
255
280
  // roster would have chosen. Said on the board, so everyone knows who has it.
@@ -267,7 +292,9 @@ export async function runTeam({
267
292
  if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
268
293
  // Who is doing this task, for a ledger that shows the lanes — said per attempt.
269
294
  say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
270
- attempts.push({ model: m.model, at: now(), continued: !!note });
295
+ routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
296
+ say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
297
+ attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
271
298
  const sent = messagesFor({ transcript }, { prompt, note });
272
299
  // The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
273
300
  // moment it exists (a tool call, its result) puts it on the record then, so a
@@ -283,6 +310,8 @@ export async function runTeam({
283
310
  });
284
311
  usage = res?.usage || null;
285
312
  if (usage) budget.charge(usage);
313
+ // What the attempt did in a checkout, when the host's harness reported one (§14).
314
+ if (normalizeScm(res?.scm)) { scm = normalizeScm(res.scm); say('task.scm', { taskId: task.id, role: role.id, ...scm }); }
286
315
  // Whatever the attempt did is the task's now — on the record, before any verdict.
287
316
  // What the host already reported step by step is not reported again.
288
317
  transcript = mergeTranscript(sent, res);
@@ -327,14 +356,14 @@ export async function runTeam({
327
356
  if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
328
357
  const thread = board.threadForTask(task.id);
329
358
  if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
330
- const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
359
+ const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
331
360
  tasksOut.push(row);
332
361
  say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
333
362
  // The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
334
363
  // in which role — produced here, attested by the store, never written by the agent.
335
364
  if (status === 'ok' || status === 'failed') {
336
365
  say('task.scored', {
337
- agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), outcome: status === 'ok' ? 'task.done' : 'task.failed',
366
+ agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), engine: routed?.engine || null, scm: scm || undefined, outcome: status === 'ok' ? 'task.done' : 'task.failed',
338
367
  size: { ms: row.ms, steps: (row.transcript || []).length, tools: (row.transcript || []).filter((m) => m.role === 'tool').length, findings: findings.length, tokens: usage ? Number(usage.input_tokens || usage.prompt_tokens || 0) + Number(usage.output_tokens || usage.completion_tokens || 0) : 0 },
339
368
  roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
340
369
  refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
@@ -383,11 +412,15 @@ export async function runTeam({
383
412
  let res = null;
384
413
  const excl = new Set();
385
414
  let judgeErr = '';
415
+ let judgeModel = null; // the appointment that answered (or the last one tried)
416
+ let judgeRoute = null;
386
417
  for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
387
418
  const mm = attempt === 1 ? (runExclude.has(m?.model) ? modelFor(judge, excluding(excl)) : m) : modelFor(judge, excluding(excl));
388
419
  if (!mm?.model) break;
389
420
  if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl], error: judgeErr });
390
421
  say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
422
+ judgeModel = mm; judgeRoute = routeOf(mm, judge, { attempt, exclude: excl });
423
+ say('task.routed', { taskId: 'merge', role: judge.id, attempt, ...judgeRoute });
391
424
  res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: mm.model, mode: 'model', system: judge.prompt, prompt, tools: judgeTools, signal, onDelta: (delta, full) => say('task.delta', { taskId: 'merge', role: judge.id, delta, text: full }) });
392
425
  if (res?.usage) budget.charge(res.usage);
393
426
  if (res?.ok && String(res.text || '').trim()) break;
@@ -397,7 +430,9 @@ export async function runTeam({
397
430
  }
398
431
  const judged = res?.ok && String(res.text || '').trim();
399
432
  say(judged ? 'task.done' : 'task.failed', { taskId: 'merge', role: judge.id, status: judged ? 'ok' : 'failed', error: judged ? null : (res?.error || 'the judge did not answer'), findings: 0 });
400
- say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: res ? (excl.size ? [...excl].at(-1) : m?.model) : m?.model, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
433
+ const judgeScm = normalizeScm(res?.scm);
434
+ if (judgeScm) say('task.scm', { taskId: 'merge', role: judge.id, ...judgeScm });
435
+ say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: judgeModel?.model || m?.model, engine: judgeRoute?.engine || null, scm: judgeScm || undefined, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
401
436
  say('run.usage', { usage: budget.snapshot() });
402
437
  proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
403
438
  } else {
package/team-tool.js CHANGED
@@ -36,7 +36,7 @@ export function teamToolSpec(teams) {
36
36
  + '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
37
37
  + '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
38
38
  + 'A team: {"name":"research" (a short identifier: letters, digits, - _; used as /research),"description":"…","roles":[{"id":"researcher","prompt":"…","prefer":"balanced","grants":["data","web"]},{"id":"writer","prompt":"…","prefer":"strong","grants":["none"]}],"merge":"judge","judge":"writer","budget":{"tokens":40000,"ms":300000}}. '
39
- + 'grants: none | data | web | history | mcp | mcp:<server>. merge: judge | converge | concat | first. A budget is required. '
39
+ + 'grants: none | data | web | history | mcp | mcp:<server> | shell | fs:write | scm:read | scm:push | scm:pr. A role may say "agent":"<id>" instead of a prompt to stand for an agent from the pool. merge: judge | converge | concat | first. A budget is required. '
40
40
  + 'Order the work with "dependsOn": a role that builds on another\'s findings (a budget checker on a researcher) lists it, so it runs after and reads the board instead of searching again. The judge does not need a task of its own - the merge is its work.',
41
41
  parameters: {
42
42
  type: 'object',
@@ -70,8 +70,16 @@ const json = (v) => JSON.stringify(v);
70
70
  * @param confirmSave `async (detail, team) => 'allow' | 'deny'`; absent = save refused
71
71
  * @param saveTeam `async (team) => void`
72
72
  */
73
- export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null } = {}) {
73
+ /**
74
+ * `resolve` is the host's `(team) => team` that fills roles standing for agents from the
75
+ * pool (agent.js resolveTeam) — applied before a dry run and before a run, never to what is
76
+ * saved: the stored team keeps its references, the run gets the cards as they are now.
77
+ */
78
+ export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null } = {}) {
74
79
  const byName = new Map(usable(teams).map((t) => [t.name, t]));
80
+ // A team whose roles stand for agents is filled from the pool on the way to a run; a
81
+ // resolver that throws (an agent missing from the pool) is the tool's error, not a crash.
82
+ const resolved = (t) => (typeof resolve === 'function' ? resolve(t) : t);
75
83
  let bound = null;
76
84
  // One run per team+request per turn. A run that failed, answered with nothing, or ran
77
85
  // out of budget comes back as a result the model must REPORT — asking for it again in the
@@ -97,7 +105,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
97
105
  if (byName.has(team.name)) return json({ error: `A team named "${team.name}" already exists. Pick another name.` });
98
106
  if (!confirmSave || !saveTeam) return json({ error: 'Saving a team needs the user\'s approval, which this surface cannot ask for. Describe the team and suggest saving it from the side panel or the desktop.' });
99
107
  const norm = normalizeTeam(team);
100
- const dry = dryRunTeam(norm, '', { appoint });
108
+ const dry = dryRunTeam(resolved(norm), '', { appoint });
101
109
  const decision = await confirmSave(describeTeamForApproval(norm, dry), norm);
102
110
  if (decision !== 'allow') return json({ error: `The user did not save "${norm.name}". Do not propose it again this turn.`, declined: true });
103
111
  const stored = { ...norm, createdAt: Date.now() };
@@ -111,7 +119,8 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
111
119
  const request = String(input?.request || '').trim();
112
120
 
113
121
  if (action === 'dry_run') {
114
- const dry = dryRunTeam(team, request, { appoint });
122
+ let dry;
123
+ try { dry = dryRunTeam(resolved(team), request, { appoint }); } catch (e) { return json({ error: e?.message || String(e) }); }
115
124
  return json({ name: team.name, ok: dry.ok, missing: dry.missing, roles: dry.roles, plan: dry.plan, tasks: dry.tasks, merge: dry.merge, budget: dry.budget });
116
125
  }
117
126
  if (action === 'run') {
@@ -123,9 +132,10 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
123
132
  const key = team.name;
124
133
  const prior = ran.get(key);
125
134
  if (prior) return json({ error: `The "${team.name}" team already ran in this turn (run ${prior.runId}, ${prior.status}). Do not run it again, even with a different request: report what it produced — ${prior.summary} — with its proposal, and ask the user how to proceed.`, runId: prior.runId, status: prior.status, tasks: prior.tasks, proposal: prior.proposal });
126
- const dry = dryRunTeam(team, request, { appoint });
135
+ let dry;
136
+ try { dry = dryRunTeam(resolved(team), request, { appoint }); } catch (e) { return json({ error: e?.message || String(e) }); }
127
137
  if (!dry.ok) return json({ error: `No model is available for role(s): ${dry.missing.join(', ')}.`, roles: dry.roles });
128
- const result = await run({ team, request, toolset: bound });
138
+ const result = await run({ team: resolved(team), request, toolset: bound });
129
139
  const findings = (result.board || []).map((f) => ({ role: f.role, kind: f.kind, text: f.text, refs: f.refs }));
130
140
  const tasks = (result.tasks || []).map((x) => ({ id: x.id, role: x.role, status: x.status, ms: x.ms, findings: (x.findings || []).length, error: x.error || undefined }));
131
141
  const failed = tasks.filter((x) => x.status !== 'ok');
package/team-trail.js CHANGED
@@ -19,6 +19,10 @@ export function teamLine(ev) {
19
19
  case 'task.handoff': return { type: 'status', text: `${role} handed off ${ev.from ? `from ${ev.from} ` : ''}to ${ev.to} by ${ev.by || 'person'}${ev.reason ? ` — ${ev.reason}` : ''}` };
20
20
  case 'task.step': return null;
21
21
  case 'task.scored': return null;
22
+ // The route is the lane's business (task.model already names it); the reasons are a line
23
+ // only when there are any — a re-appointment says its own.
24
+ case 'task.routed': return ev.reasons?.length && ev.attempt === 1 ? { type: 'status', text: `${role} → ${ev.engine?.id || '?'}${ev.engine?.model ? `/${ev.engine.model}` : ''} (${ev.reasons.join('; ')})` } : null;
25
+ case 'task.scm': return ev.commits ? { type: 'status', text: `${role} committed ${ev.commits} on ${ev.branch || 'a branch'}${ev.headAfter ? ` @ ${String(ev.headAfter).slice(0, 7)}` : ''}` } : null;
22
26
  case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
23
27
  case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
24
28
  case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
@@ -40,6 +44,8 @@ export function teamLanes(prev, ev) {
40
44
  case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
41
45
  case 'task.finding': lanes.findings += 1; if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], findings: (lanes.tasks[ev.taskId].findings || 0) + 1 }; break;
42
46
  case 'task.model': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.model }; break;
47
+ case 'task.routed': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], engine: ev.engine || null }; break;
48
+ case 'task.scm': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], scm: { branch: ev.branch, commits: ev.commits || 0, head: ev.headAfter || ev.head } }; break;
43
49
  case 'task.handoff': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.to, handoffs: (lanes.tasks[ev.taskId].handoffs || 0) + 1 }; break;
44
50
  case 'task.step': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], steps: (lanes.tasks[ev.taskId].steps || 0) + (ev.steps || []).length }; break;
45
51
  case 'task.tool': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], tools: (lanes.tasks[ev.taskId].tools || 0) + 1, lastTool: ev.text ? `${ev.name} ${ev.text}` : ev.name }; break;
package/team.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // survive an `origin`, and a team a client stores as trusted is stored as nothing of the kind.
17
17
 
18
18
  import { validateBudget, normalizeBudget } from './budget.js';
19
+ import { normalizeEngineSpec, validateEngineSpec, tierOf } from './engine.js';
19
20
 
20
21
  export const TEAM_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
21
22
  export const ROLE_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/i;
@@ -23,10 +24,22 @@ export const ROLE_MODES = Object.freeze(['model', 'subagent', 'recipe']);
23
24
  export const ROLE_PREFERS = Object.freeze(['cheap', 'balanced', 'strong']);
24
25
  export const MERGE_POLICIES = Object.freeze(['judge', 'converge', 'concat', 'first']);
25
26
  export const PLAN_MODES = Object.freeze(['fixed', 'planner']);
26
- /** The tool groups a role may hold. `mcp:<server>` narrows to one server; `mcp` is all of them. */
27
- export const GRANTABLE = Object.freeze(['none', 'data', 'web', 'mcp', 'history']);
28
- export const GRANT_RE = /^(none|data|web|history|mcp|mcp:[a-zA-Z0-9_.:-]{1,64})$/;
27
+ /**
28
+ * The tool groups a role may hold. `mcp:<server>` narrows to one server; `mcp` is all of them.
29
+ *
30
+ * The work grants (architecture-pillars.md §14.2) are for an agent whose engine is a harness
31
+ * running in a checkout: `shell` and `fs:write` say so explicitly instead of riding along
32
+ * with the harness; `scm:read` reads the repo and its hub, `scm:push` pushes ITS OWN branch
33
+ * (`cp/<project>/<job>`), `scm:pr` opens a pull request, and `scm:merge` is held by the
34
+ * Gate — grantable only where the org's `gate.json` allows it. A chat-model role that holds
35
+ * one of these holds nothing: only a harness engine can use them, and the bridge enforces it.
36
+ */
37
+ export const GRANTABLE = Object.freeze(['none', 'data', 'web', 'mcp', 'history', 'shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr', 'scm:merge']);
38
+ export const WORK_GRANTS = Object.freeze(['shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr', 'scm:merge']);
39
+ export const GRANT_RE = /^(none|data|web|history|mcp|mcp:[a-zA-Z0-9_.:-]{1,64}|shell|fs:write|scm:(read|push|pr|merge))$/;
29
40
  export const MAX_ROLES = 8;
41
+ /** A role that stands for an agent from the pool: `agent` names it (agent.js `AGENT_ID_RE`). */
42
+ export const AGENT_REF_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
30
43
 
31
44
  export class TeamError extends Error {
32
45
  constructor(code, message) { super(message); this.name = 'TeamError'; this.code = code; }
@@ -59,7 +72,11 @@ export function validateTeam(team) {
59
72
  if (r.mode !== undefined && !ROLE_MODES.includes(r.mode)) errors.push(`${w}.mode: one of ${ROLE_MODES.join(', ')}`);
60
73
  if (r.prefer !== undefined && !ROLE_PREFERS.includes(r.prefer)) errors.push(`${w}.prefer: one of ${ROLE_PREFERS.join(', ')}`);
61
74
  if ((r.mode || 'model') === 'recipe' && !r.recipe) errors.push(`${w}.recipe: a recipe name is required in recipe mode`);
62
- if ((r.mode || 'model') !== 'recipe' && !String(r.prompt || '').trim()) errors.push(`${w}.prompt: what this role does`);
75
+ if (r.agent !== undefined && r.agent !== null && !AGENT_REF_RE.test(String(r.agent))) errors.push(`${w}.agent: an agent id`);
76
+ // A role that stands for an agent takes its prompt from the pool (agent.js resolveTeam);
77
+ // a role that stands for nobody must say what it does.
78
+ if ((r.mode || 'model') !== 'recipe' && !r.agent && !String(r.prompt || '').trim()) errors.push(`${w}.prompt: what this role does`);
79
+ errors.push(...validateEngineSpec(r.engine, `${w}.engine`));
63
80
  const bad = (Array.isArray(r.grants) ? r.grants : []).filter((g) => !GRANT_RE.test(String(g)));
64
81
  if (bad.length) errors.push(`${w}.grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; a team may not act on it)' : ''}`);
65
82
  });
@@ -89,12 +106,21 @@ export function normalizeTeam(team, { builtin = false } = {}) {
89
106
  id: String(r.id),
90
107
  name: String(r.name || r.id).slice(0, 60),
91
108
  mode: ROLE_MODES.includes(r.mode) ? r.mode : 'model',
92
- prefer: ROLE_PREFERS.includes(r.prefer) ? r.prefer : 'balanced',
109
+ prefer: ROLE_PREFERS.includes(r.prefer) ? r.prefer : (r.engine ? tierOf(r.engine) : 'balanced'),
93
110
  ...(r.model ? { model: String(r.model) } : {}),
111
+ ...(r.agent ? { agent: String(r.agent) } : {}),
112
+ ...(r.engine ? { engine: normalizeEngineSpec(r.engine) } : {}),
94
113
  prompt: String(r.prompt || '').trim().slice(0, 4000),
95
- grants: normalizeGrants(r.grants),
114
+ // A role that stands for an agent holds the agent's grants unless it narrows them: no
115
+ // list means "the agent's", so the key is left out rather than stored as `none`.
116
+ ...(r.agent && !(Array.isArray(r.grants) && r.grants.length) ? {} : { grants: normalizeGrants(r.grants) }),
96
117
  ...(r.recipe ? { recipe: String(r.recipe) } : {}),
97
118
  ...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String).filter((d) => d !== r.id) } : {}),
119
+ // What agent.js resolveTeam fills from the pool; kept so the runner's roles carry it.
120
+ ...(Array.isArray(r.skills) && r.skills.length ? { skills: r.skills.map(String).slice(0, 32) } : {}),
121
+ ...(r.workdir ? { workdir: String(r.workdir).slice(0, 400) } : {}),
122
+ ...(r.egress === 'redacted' || r.egress === 'delegated' ? { egress: r.egress } : {}),
123
+ ...(r.memoryScope ? { memoryScope: String(r.memoryScope).slice(0, 120) } : {}),
98
124
  })),
99
125
  budget: normalizeBudget(team.budget),
100
126
  enabled: team.enabled !== false,
@@ -115,11 +141,25 @@ export function grantAllows(grants, groupId, serverId = '') {
115
141
  return g.includes(groupId);
116
142
  }
117
143
 
144
+ /**
145
+ * The SCM ladder: `merge` ⊃ `pr` ⊃ `push` ⊃ `read` — a role that may open a PR may push the
146
+ * branch the PR is from, and anyone who may push may read. `push` is the role's OWN branch
147
+ * only; the bridge names it (`cp/<project>/<job>`) and refuses any other.
148
+ */
149
+ const SCM_LADDER = ['read', 'push', 'pr', 'merge'];
150
+ export function scmAllows(grants, action) {
151
+ const g = normalizeGrants(grants);
152
+ const want = SCM_LADDER.indexOf(String(action || '').replace(/^scm:/, ''));
153
+ if (want < 0 || g.includes('none')) return false;
154
+ const held = Math.max(-1, ...g.filter((x) => x.startsWith('scm:')).map((x) => SCM_LADDER.indexOf(x.slice(4))));
155
+ return held >= want;
156
+ }
157
+
118
158
  /** One line a person reads per role: name · tier/model · grants · mode. */
119
159
  export function describeRole(r) {
120
160
  const who = r.model || r.prefer || 'balanced';
121
161
  const grants = (r.grants || ['none']).join(', ');
122
- return `${r.name || r.id} — ${who}${r.mode && r.mode !== 'model' ? ` (${r.mode})` : ''} · tools: ${grants}`;
162
+ return `${r.name || r.id}${r.agent ? ` (agent: ${r.agent})` : ''} — ${who}${r.mode && r.mode !== 'model' ? ` (${r.mode})` : ''} · tools: ${grants}`;
123
163
  }
124
164
 
125
165
  // ── Starters and the editor's form ───────────────────────────────────────────────────────
@@ -150,6 +190,55 @@ export const STARTER_TEAMS = Object.freeze([
150
190
  ],
151
191
  budget: { tokens: 30000, ms: 240000 },
152
192
  },
193
+ // ── The engineering teams (architecture-pillars.md §12.2) — roles stand for the standing
194
+ // agents in agent.js STARTER_AGENTS; a role's prompt is the agent's, its engine the agent's.
195
+ // A feature that crosses repos recruits one Implementer per repo (the role says which via
196
+ // its prompt); these starters name one.
197
+ {
198
+ name: 'feature',
199
+ description: 'Architect plans, an Implementer builds on a branch, Reviewer and Tester check, Scribe writes it up. The Architect judges.',
200
+ plan: 'planner', merge: 'judge', judge: 'architect',
201
+ roles: [
202
+ { id: 'architect', agent: 'architect' },
203
+ { id: 'implementer', agent: 'implementer', dependsOn: ['architect'] },
204
+ { id: 'reviewer', agent: 'reviewer', dependsOn: ['implementer'] },
205
+ { id: 'tester', agent: 'tester', dependsOn: ['implementer'] },
206
+ { id: 'scribe', agent: 'scribe', dependsOn: ['reviewer', 'tester'] },
207
+ ],
208
+ budget: { tokens: 400000, ms: 3600000 },
209
+ },
210
+ {
211
+ name: 'fix',
212
+ description: 'One Implementer fixes it on a branch, the Tester runs the guard, the Scribe notes it.',
213
+ plan: 'fixed', merge: 'concat',
214
+ roles: [
215
+ { id: 'implementer', agent: 'implementer' },
216
+ { id: 'tester', agent: 'tester', dependsOn: ['implementer'] },
217
+ { id: 'scribe', agent: 'scribe', dependsOn: ['tester'] },
218
+ ],
219
+ budget: { tokens: 150000, ms: 1800000 },
220
+ },
221
+ {
222
+ name: 'docs',
223
+ description: 'The Architect decides what the docs should say; the Scribe proposes the text.',
224
+ plan: 'fixed', merge: 'concat',
225
+ roles: [
226
+ { id: 'architect', agent: 'architect' },
227
+ { id: 'scribe', agent: 'scribe', dependsOn: ['architect'] },
228
+ ],
229
+ budget: { tokens: 80000, ms: 900000 },
230
+ },
231
+ {
232
+ name: 'release',
233
+ description: 'The Tester runs the guard on the merged branch, Release bumps and asks before publishing, the Scribe records the version.',
234
+ plan: 'fixed', merge: 'concat',
235
+ roles: [
236
+ { id: 'tester', agent: 'tester' },
237
+ { id: 'release', agent: 'release', dependsOn: ['tester'] },
238
+ { id: 'scribe', agent: 'scribe', dependsOn: ['release'] },
239
+ ],
240
+ budget: { tokens: 60000, ms: 1800000 },
241
+ },
153
242
  ]);
154
243
 
155
244
  /**
@@ -163,7 +252,7 @@ export function slugTeamName(name) {
163
252
 
164
253
  /** Fresh copies — a starter is a template, never the stored record. */
165
254
  export function starterTeams() {
166
- return STARTER_TEAMS.map((t) => ({ ...t, roles: t.roles.map((r) => ({ ...r, grants: [...r.grants] })), budget: { ...t.budget } }));
255
+ return STARTER_TEAMS.map((t) => ({ ...t, roles: t.roles.map((r) => ({ ...r, ...(r.grants ? { grants: [...r.grants] } : {}), ...(r.dependsOn ? { dependsOn: [...r.dependsOn] } : {}) })), budget: { ...t.budget } }));
167
256
  }
168
257
 
169
258
  /** A blank team for the editor: one role, the smallest budget that is still a budget. */
@@ -176,6 +265,7 @@ export function blankTeam() {
176
265
  * the budget as numbers that may be blank; a blank judge under `merge: judge` is the last
177
266
  * role, which is the writer in every starter.
178
267
  */
268
+ const grantsText = (g) => String(Array.isArray(g) ? g.join(',') : g || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
179
269
  export function teamFromForm(form) {
180
270
  const roles = (Array.isArray(form.roles) ? form.roles : []).map((r) => ({
181
271
  id: String(r.id || '').trim(),
@@ -183,7 +273,12 @@ export function teamFromForm(form) {
183
273
  prompt: String(r.prompt || ''),
184
274
  prefer: r.prefer || 'balanced',
185
275
  ...(r.model ? { model: String(r.model) } : {}),
186
- grants: String(Array.isArray(r.grants) ? r.grants.join(',') : r.grants || 'none').split(/[,\s]+/).map((g) => g.trim()).filter(Boolean),
276
+ ...(r.agent ? { agent: String(r.agent).trim() } : {}),
277
+ ...(r.engine ? { engine: r.engine } : {}),
278
+ ...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String) } : {}),
279
+ // Blank grants on a role that stands for an agent mean "the agent's"; on any other role
280
+ // they mean none.
281
+ ...(grantsText(r.grants).length ? { grants: grantsText(r.grants) } : r.agent ? {} : { grants: ['none'] }),
187
282
  }));
188
283
  const budget = {};
189
284
  for (const k of ['tokens', 'calls', 'ms', 'usd']) {
@@ -0,0 +1,98 @@
1
+ // WHOSE VOICE IS THIS — the gate that keeps a conversation to the person having it.
2
+ //
3
+ // In a voice conversation every finalized sentence is SENT, so anything the microphone hears
4
+ // becomes a question: a television, a colleague at the next desk, someone answering their own
5
+ // phone behind you. The engine transcribes them all perfectly and correctly, and the
6
+ // assistant answers the room.
7
+ //
8
+ // The gateway already computes a 512-d speaker fingerprint per committed segment and clusters
9
+ // it into a stable label for the session (`diarize-engine.js`). All that is missing is the
10
+ // DECISION, which is this file: the first voice in a conversation is the person who started
11
+ // it, and later sentences from a different voice are not their turn.
12
+ //
13
+ // THE FAILURE DIRECTION MATTERS MORE THAN THE FEATURE. An assistant that occasionally answers
14
+ // the television is annoying; one that ignores YOU is broken, and from the outside the two
15
+ // look identical — a mic that is open and going nowhere. So every uncertain case sends:
16
+ //
17
+ // · no speaker on the final (diarization off, model missing, embedding failed) → send;
18
+ // · no primary enrolled yet → this is the first voice, enroll it and send;
19
+ // · the primary has not been heard for a long time → adopt whoever is talking now and
20
+ // send, because the phone may have been handed over, or the first voice may have been
21
+ // the television while the user was drawing breath.
22
+ //
23
+ // Only one case holds: a DIFFERENT voice, while the person having the conversation is still
24
+ // in it. That is the case the user asked for and the only one we can be confident about.
25
+ //
26
+ // The engine's own honesty note applies here too: embeddings separate different speakers
27
+ // well, but similar voices can merge — so this gate can let a very similar voice through. It
28
+ // never claims to be security, only to keep the room out of the conversation.
29
+
30
+ /** How long the primary must be silent before another voice may take over the conversation. */
31
+ export const RE_ENROLL_MS = 45_000;
32
+
33
+ /** A label the gateway pins to the microphone channel; never a guess, so always the primary. */
34
+ export const PINNED_SELF = 'You';
35
+
36
+ const labelOf = (speaker) => {
37
+ if (!speaker) return '';
38
+ if (typeof speaker === 'string') return speaker;
39
+ return String(speaker.label || speaker.id || '');
40
+ };
41
+
42
+ /**
43
+ * A per-conversation speaker gate.
44
+ *
45
+ * @param {object} [opts]
46
+ * @param {number} [opts.reEnrollMs] silence after which another voice may take over
47
+ * @param {() => number} [opts.now] injected clock, so the rules are testable without waiting
48
+ */
49
+ export function createSpeakerGate({ reEnrollMs = RE_ENROLL_MS, now = Date.now } = {}) {
50
+ let primary = ''; // the label of the person having this conversation
51
+ let lastHeard = 0; // when the primary last said something
52
+ let held = 0; // sentences kept out, for the UI to report honestly
53
+
54
+ return {
55
+ /** The enrolled voice, or '' before anyone has spoken. */
56
+ primary: () => primary,
57
+ /** How many sentences this gate has held back. */
58
+ heldCount: () => held,
59
+
60
+ /**
61
+ * Should this finalized sentence become a turn?
62
+ *
63
+ * @param {{ speaker?: any }} [final]
64
+ * @returns {{ send: boolean, reason: 'no-speaker'|'enrolled'|'primary'|'adopted'|'other', speaker: string }}
65
+ */
66
+ admit(final = {}) {
67
+ const label = labelOf(final.speaker);
68
+ // Diarization is optional and fails open — a sentence with no speaker is always sent.
69
+ if (!label) return { send: true, reason: 'no-speaker', speaker: '' };
70
+
71
+ const t = now();
72
+ if (!primary) {
73
+ primary = label;
74
+ lastHeard = t;
75
+ return { send: true, reason: 'enrolled', speaker: label };
76
+ }
77
+ if (label === primary) {
78
+ lastHeard = t;
79
+ return { send: true, reason: 'primary', speaker: label };
80
+ }
81
+ // A different voice. Only take over when the conversation has clearly moved on.
82
+ if (t - lastHeard >= reEnrollMs) {
83
+ primary = label;
84
+ lastHeard = t;
85
+ return { send: true, reason: 'adopted', speaker: label };
86
+ }
87
+ held += 1;
88
+ return { send: false, reason: 'other', speaker: label };
89
+ },
90
+
91
+ /**
92
+ * Forget who was talking. Called when a conversation starts, and by a user who wants the
93
+ * gate to hear them again — a voice it merged or mislabelled must be recoverable without
94
+ * ending the session.
95
+ */
96
+ reset() { primary = ''; lastHeard = 0; held = 0; },
97
+ };
98
+ }