@chatpanel/events 0.84.1 → 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/scorecard.js ADDED
@@ -0,0 +1,335 @@
1
+ // An agent's scorecard — an immutable record of what it actually did, and matching on it.
2
+ //
3
+ // A scorecard is a CHAIN of entries, one per fact, append-only: a task it finished (how big,
4
+ // with which tools, alongside whom, in which role), a rating a job gave it, an agent it
5
+ // created, an interaction. Every entry carries the hash of the one before and its own hash,
6
+ // so an edit anywhere breaks every link after it; the gateway's store adds its own mark
7
+ // (an HMAC over the hash with a key only the store holds) so an entry a client — or an
8
+ // agent — wrote for itself shows as unattested. The facts are produced by the runner and
9
+ // attested by the store, never written by the agent: the only way to a better scorecard is
10
+ // the work.
11
+ //
12
+ // `summarize` turns the chain into the card a recruiter reads; `fit` scores an agent type
13
+ // against a job's needs with that record — the same function the evaluator starts from, so
14
+ // an application's fit has reasons a person can read and overrule.
15
+ //
16
+ // The model is a variable, not a constant (architecture-pillars.md §13): every task fact also
17
+ // says which ENGINE did it — a model endpoint or a harness (a CLI coding agent), and for a
18
+ // harness the model it was asked to run — so `summarize` can split the card by engine
19
+ // (`byEngine`) and a recruiter can tell an agent that did well on a small model from one
20
+ // that was carried by a large one. And where the task ran in a git checkout (§14), the fact
21
+ // carries `scm`: the branch and HEAD before and after, commits made, a PR when one was
22
+ // opened, and whether it merged — the one outcome that does not come from a judge.
23
+ //
24
+ // Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
25
+
26
+ export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
27
+ export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
28
+ export const SCORECARD_VERSION = 1;
29
+ export const ENGINE_KINDS = Object.freeze(['model', 'harness']);
30
+
31
+ /**
32
+ * An engine as the record keeps it: `{ kind, id, model?, label? }`. `kind` is `model` (an
33
+ * endpoint the client calls) or `harness` (a CLI coding agent the bridge runs — `id` is the
34
+ * harness, `model` the model it was asked to run, when one was named). A host that does not
35
+ * say the kind gets `model`, which is the honest default for a bare model id. A string is
36
+ * an id.
37
+ */
38
+ export function normalizeEngine(e) {
39
+ if (!e) return null;
40
+ const src = typeof e === 'string' ? { id: e } : e;
41
+ const id = String(src.id || src.harnessId || src.model || '').trim();
42
+ if (!id) return null;
43
+ const kind = ENGINE_KINDS.includes(src.kind) ? src.kind : (src.harnessId ? 'harness' : 'model');
44
+ const model = src.model != null && String(src.model).trim() && String(src.model) !== id ? String(src.model).trim() : undefined;
45
+ return { kind, id, ...(model ? { model } : {}), ...(src.label && String(src.label) !== id ? { label: String(src.label).slice(0, 120) } : {}) };
46
+ }
47
+
48
+ /** One key per engine — what `byEngine` groups on and what the model ledger will be keyed by. */
49
+ export function engineKey(e) {
50
+ const n = normalizeEngine(e);
51
+ return n ? `${n.kind}:${n.id}${n.model ? `/${n.model}` : ''}` : null;
52
+ }
53
+
54
+ /** What a task did in a checkout, as the record keeps it. Strings clipped, counts rounded. */
55
+ export function normalizeScm(s) {
56
+ if (!s || typeof s !== 'object') return null;
57
+ const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).slice(0, n));
58
+ const out = {
59
+ repo: str(s.repo, 300), remote: str(s.remote, 300), base: str(s.base, 120), branch: str(s.branch, 120),
60
+ head: str(s.head, 64), headAfter: str(s.headAfter, 64),
61
+ commits: s.commits != null ? Math.max(0, Math.round(Number(s.commits) || 0)) : undefined,
62
+ pr: str(s.pr, 300),
63
+ merged: s.merged === true ? true : s.merged === false ? false : undefined,
64
+ dirty: s.dirty === true ? true : s.dirty === false ? false : undefined,
65
+ };
66
+ for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
67
+ return Object.keys(out).length ? out : null;
68
+ }
69
+
70
+ const enc = new TextEncoder();
71
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
72
+
73
+ /** Canonical JSON: keys sorted at every level, so the same fact hashes the same everywhere. */
74
+ export function canonical(v) {
75
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
76
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
77
+ return `{${Object.keys(v).sort().map((k) => (v[k] === undefined ? null : `${JSON.stringify(k)}:${canonical(v[k])}`)).filter(Boolean).join(',')}}`;
78
+ }
79
+
80
+ /** SHA-256 over text, as hex; `subtle` is injectable (a runtime without it passes its own). */
81
+ export async function sha256(text, { subtle = globalThis.crypto?.subtle } = {}) {
82
+ if (!subtle) throw new Error('scorecard: no crypto.subtle — pass one');
83
+ return hex(await subtle.digest('SHA-256', enc.encode(String(text))));
84
+ }
85
+
86
+ /** The fields a hash covers — everything but the hash and the store's mark. */
87
+ function hashable(e) {
88
+ const { hash: _h, sig: _s, ...rest } = e;
89
+ return rest;
90
+ }
91
+
92
+ /**
93
+ * A new entry chained onto `prev` (the last entry, or null for the first). Pure apart from
94
+ * the digest: the caller (the store) decides whether it is attested.
95
+ */
96
+ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
97
+ if (!fact || typeof fact !== 'object') throw new Error('scorecard: an entry needs a fact');
98
+ if (!SCORECARD_ENTRY_KINDS.includes(fact.kind)) throw new Error(`scorecard: kind must be one of ${SCORECARD_ENTRY_KINDS.join(', ')}`);
99
+ if (!fact.agentId) throw new Error('scorecard: agentId required');
100
+ const e = {
101
+ v: SCORECARD_VERSION,
102
+ seq: prev ? prev.seq + 1 : 0,
103
+ agentId: String(fact.agentId),
104
+ kind: fact.kind,
105
+ at: Number(fact.at) || now(),
106
+ ...(fact.projectId ? { projectId: String(fact.projectId) } : {}),
107
+ ...(fact.jobId ? { jobId: String(fact.jobId) } : {}),
108
+ ...(fact.runId ? { runId: String(fact.runId) } : {}),
109
+ ...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
110
+ ...(fact.model ? { model: String(fact.model) } : {}),
111
+ ...(normalizeEngine(fact.engine) ? { engine: normalizeEngine(fact.engine) } : {}),
112
+ ...(normalizeScm(fact.scm) ? { scm: normalizeScm(fact.scm) } : {}),
113
+ ...(fact.size ? { size: sizeOf(fact.size) } : {}),
114
+ ...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
115
+ ...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
116
+ ...(Array.isArray(fact.with) && fact.with.length ? { with: [...new Set(fact.with.map(String))].sort() } : {}),
117
+ ...(Array.isArray(fact.created) && fact.created.length ? { created: [...new Set(fact.created.map(String))] } : {}),
118
+ ...(fact.rating ? { rating: { by: String(fact.rating.by || 'person'), score: clamp01(fact.rating.score), ...(fact.rating.note ? { note: String(fact.rating.note).slice(0, 500) } : {}), ...(fact.rating.about != null ? { about: Number(fact.rating.about) } : {}) } } : {}),
119
+ ...(Array.isArray(fact.refs) && fact.refs.length ? { refs: fact.refs.map(String).slice(0, 12) } : {}),
120
+ ...(fact.error ? { error: String(fact.error).slice(0, 300) } : {}),
121
+ prev: prev ? prev.hash : null,
122
+ };
123
+ e.hash = await sha256(canonical(hashable(e)), { subtle });
124
+ return e;
125
+ }
126
+
127
+ const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
128
+ const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
129
+ const sizeOf = (s) => ({ ms: Math.max(0, Math.round(Number(s.ms) || 0)), steps: Math.max(0, Math.round(Number(s.steps) || 0)), tools: Math.max(0, Math.round(Number(s.tools) || 0)), findings: Math.max(0, Math.round(Number(s.findings) || 0)), tokens: Math.max(0, Math.round(Number(s.tokens) || 0)) });
130
+
131
+ /** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
132
+ export async function verifyChain(entries, { subtle } = {}) {
133
+ let prev = null;
134
+ for (const e of entries || []) {
135
+ if (!e || typeof e !== 'object') return { ok: false, at: prev ? prev.seq + 1 : 0, why: 'not an entry' };
136
+ if ((prev ? prev.seq + 1 : 0) !== e.seq) return { ok: false, at: e.seq, why: 'seq' };
137
+ if ((prev ? prev.hash : null) !== e.prev) return { ok: false, at: e.seq, why: 'prev' };
138
+ const h = await sha256(canonical(hashable(e)), { subtle });
139
+ if (h !== e.hash) return { ok: false, at: e.seq, why: 'hash' };
140
+ prev = e;
141
+ }
142
+ return { ok: true, at: null, length: (entries || []).length };
143
+ }
144
+
145
+ /**
146
+ * The store's mark. `key` is raw bytes only the store holds; an HMAC-SHA-256 over the hash.
147
+ * Anyone can re-hash the chain; only the store can mark it, so an entry written elsewhere
148
+ * is honest about being unattested. Injectable `subtle` again.
149
+ */
150
+ export async function attest(entry, key, { subtle = globalThis.crypto?.subtle } = {}) {
151
+ const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
152
+ return { ...entry, sig: hex(await subtle.sign('HMAC', k, enc.encode(entry.hash))) };
153
+ }
154
+ export async function verifyAttested(entries, key, { subtle = globalThis.crypto?.subtle } = {}) {
155
+ const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
156
+ const out = [];
157
+ for (const e of entries || []) {
158
+ const sig = e?.sig ? new Uint8Array(e.sig.match(/../g).map((x) => parseInt(x, 16))) : null;
159
+ out.push(!!sig && await subtle.verify('HMAC', k, sig, enc.encode(e.hash)));
160
+ }
161
+ return { ok: out.every(Boolean), attested: out.filter(Boolean).length, of: out.length };
162
+ }
163
+
164
+ /** The card a recruiter reads. */
165
+ export function summarize(entries, { recent = 5 } = {}) {
166
+ const list = (entries || []).filter((e) => e && e.kind);
167
+ const done = list.filter((e) => e.kind === 'task.done');
168
+ const failed = list.filter((e) => e.kind === 'task.failed');
169
+ const sum = (k) => done.reduce((n, e) => n + (e.size?.[k] || 0), 0);
170
+ const largest = done.reduce((m, e) => Math.max(m, e.size?.steps || 0), 0);
171
+ const tools = new Set(); const withAgents = new Set(); const created = new Set();
172
+ const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
173
+ const models = new Map();
174
+ // Per engine: how many tasks, how they went, how big, and the ratings that were ABOUT a
175
+ // task on it. A rating names its task by `about` (the seq of the entry it rates), by
176
+ // taskId, or — failing both — by runId when that run had exactly one task on the card.
177
+ const engines = new Map(); // key -> { engine, tasks, done, failed, tokens, ratings[] }
178
+ const engineOfEntry = new Map(); // seq -> key
179
+ const byTask = new Map(); const byRun = new Map(); // taskId -> seq, runId -> [seq]
180
+ const scm = { tasks: 0, commits: 0, prs: 0, merged: 0 };
181
+ for (const e of list) {
182
+ for (const t of e.tools || []) tools.add(t);
183
+ for (const a of e.with || []) withAgents.add(a);
184
+ for (const a of e.created || []) created.add(a);
185
+ if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
186
+ if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
187
+ if (e.kind === 'task.done' || e.kind === 'task.failed') {
188
+ const key = engineKey(e.engine);
189
+ if (key) {
190
+ const row = engines.get(key) || { engine: normalizeEngine(e.engine), tasks: 0, done: 0, failed: 0, tokens: 0, ratings: [] };
191
+ row.tasks += 1; row[e.kind === 'task.done' ? 'done' : 'failed'] += 1; row.tokens += e.size?.tokens || 0;
192
+ engines.set(key, row);
193
+ engineOfEntry.set(e.seq, key);
194
+ if (e.taskId) byTask.set(`${e.runId || ''}/${e.taskId}`, e.seq);
195
+ if (e.runId) byRun.set(e.runId, [...(byRun.get(e.runId) || []), e.seq]);
196
+ }
197
+ if (e.scm) { scm.tasks += 1; scm.commits += e.scm.commits || 0; if (e.scm.pr) scm.prs += 1; if (e.scm.merged) scm.merged += 1; }
198
+ }
199
+ }
200
+ for (const e of list) {
201
+ if (e.kind !== 'rating' || !e.rating) continue;
202
+ const seq = e.rating.about != null ? e.rating.about
203
+ : e.taskId && byTask.has(`${e.runId || ''}/${e.taskId}`) ? byTask.get(`${e.runId || ''}/${e.taskId}`)
204
+ : e.runId && (byRun.get(e.runId) || []).length === 1 ? byRun.get(e.runId)[0] : null;
205
+ const key = seq != null ? engineOfEntry.get(seq) : null;
206
+ if (key) engines.get(key).ratings.push(e.rating.score);
207
+ }
208
+ const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
209
+ const byEngine = [...engines.values()].sort((a, b) => b.tasks - a.tasks).map((r) => ({
210
+ key: engineKey(r.engine), ...r.engine, tasks: r.tasks, done: r.done, failed: r.failed,
211
+ failRate: r.tasks ? Math.round((r.failed / r.tasks) * 1000) / 1000 : 0,
212
+ tokens: r.tasks ? Math.round(r.tokens / r.tasks) : 0, // mean per task — the cost proxy until the ledger prices it
213
+ rating: { avg: mean(r.ratings), count: r.ratings.length },
214
+ }));
215
+ // 1 − spread of rating across engines it was rated on (≥ 2): low spread = robust to routing;
216
+ // high spread = it NEEDS a particular engine, a fact a recruiter respects, not a penalty.
217
+ const ratedEngines = byEngine.filter((r) => r.rating.avg != null).map((r) => r.rating.avg);
218
+ const engineIndependence = ratedEngines.length >= 2 ? Math.round((1 - (Math.max(...ratedEngines) - Math.min(...ratedEngines))) * 1000) / 1000 : null;
219
+ // Leverage (rating above the engine's own mean) and efficiency (rating ÷ cost) wait on the
220
+ // model ledger (§13.2, with A1): they need every engine's mean, which one card cannot know.
221
+ const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
222
+ const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
223
+ const recentRatings = ratings.slice(-recent);
224
+ const refs = [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent);
225
+ return {
226
+ agentId: list[0]?.agentId || null,
227
+ entries: list.length,
228
+ jobsDone: done.length,
229
+ jobsFailed: failed.length,
230
+ size: { ms: sum('ms'), steps: sum('steps'), tools: sum('tools'), findings: sum('findings'), tokens: sum('tokens'), largestSteps: largest },
231
+ tools: [...tools].sort(),
232
+ workedWith: [...withAgents].sort(),
233
+ created: [...created],
234
+ roles,
235
+ models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
236
+ byEngine,
237
+ engineIndependence,
238
+ scm,
239
+ rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
240
+ refs,
241
+ since: list[0]?.at || null,
242
+ last: list.at(-1)?.at || null,
243
+ head: list.at(-1)?.hash || null,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * How well an agent TYPE fits a job, with that type's record. `job.needs` is
249
+ * `{ skills[], tools[], grants[] }`; `type` carries `skills[]`, `tools[]`, `grants[]`; the
250
+ * summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
251
+ * without the tools cannot do the job), track record second, size third.
252
+ */
253
+ export function fit(job, type, summary = null, { qualityOf = null, costOf = null, adjust = true } = {}) {
254
+ const needs = job?.needs || {};
255
+ const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
256
+ const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
257
+ const reasons = [];
258
+ const coverage = (want, has, label) => {
259
+ const w = (want || []).map((x) => String(x).toLowerCase());
260
+ if (!w.length) return 1;
261
+ const hit = w.filter((x) => has.has(x));
262
+ if (hit.length < w.length) reasons.push(`missing ${label}: ${w.filter((x) => !has.has(x)).join(', ')}`);
263
+ return hit.length / w.length;
264
+ };
265
+ const cSkills = coverage(needs.skills, skills, 'skills');
266
+ const cTools = coverage(needs.tools, tools, 'tools');
267
+ const cGrants = coverage(needs.grants, grants, 'grants');
268
+ const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
269
+ if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
270
+ let record = 0.5; // a fresh type is neither trusted nor distrusted
271
+ let adjusted = null;
272
+ if (summary && summary.entries) {
273
+ const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
274
+ // The track record uses the MODEL-ADJUSTED rating when the caller can say what the
275
+ // engines were worth (§13.3): an agent rated 0.82 mostly on a weak engine ranks above
276
+ // one rated 0.82 on a frontier model. The reasons say so, and a person can turn it off.
277
+ adjusted = adjust && qualityOf && summary.rating.avg != null ? adjustSummary(summary, { qualityOf, costOf: costOf || undefined }) : null;
278
+ const rated = summary.rating.avg == null ? 0.5 : (adjusted?.adjusted ?? summary.rating.avg);
279
+ record = doneRate * 0.5 + rated * 0.5;
280
+ reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? (adjusted && adjusted.adjusted !== adjusted.raw ? `, rated ${Math.round(adjusted.raw * 100)}% raw, ${Math.round(adjusted.adjusted * 100)}% adjusted — ${adjusted.basis[0] || 'engine-corrected'}` : `, rated ${Math.round(summary.rating.avg * 100)}%`) : ''}`);
281
+ if (adjusted?.leverage != null && adjusted.leverage > 0.05) reasons.push(`adds ${adjusted.leverage} over its engines' own quality`);
282
+ if (adjusted?.efficiency) reasons.push(`cleared the bar cheapest on ${adjusted.efficiency.engine}`);
283
+ if (summary.roles.orchestrator + summary.roles.manager + summary.roles['manager-of-managers'] > 0) reasons.push(`has led: ${summary.roles.orchestrator} as orchestrator, ${summary.roles.manager} as manager`);
284
+ } else {
285
+ reasons.push('no record yet');
286
+ }
287
+ const wantSize = Number(job?.size?.steps) || 0;
288
+ const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
289
+ if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
290
+ const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
291
+ return { score, reasons, parts: { needs: needScore, record, size: sizeScore }, ...(adjusted ? { adjusted } : {}) };
292
+ }
293
+
294
+ // ── Agent scores, normalised by engine (§13.3) — what the model ledger's cards make possible ──
295
+
296
+ /**
297
+ * The model-adjusted view of an agent's card (scorecard.js `summarize()`), given what its
298
+ * engines are worth: `qualityOf(key)` → the engine's quality in [0, 1] (the card's mean
299
+ * rating for the job kind when observed, else the router's guess) or null when unknown.
300
+ *
301
+ * leverage rating on an engine minus that engine's quality, weighted by tasks — what
302
+ * the agent's prompt and tools add that the model does not supply on its own
303
+ * adjusted the raw rating corrected for the engines it ran on: work done on a weak
304
+ * engine counts for more, on a strong one for less; `k` bounds the correction
305
+ * efficiency adjusted rating ÷ cost per task on the cheapest engine that cleared `bar`
306
+ * (`costOf(key)` → $/task or a token proxy; null when nothing is priced)
307
+ *
308
+ * Returns `{ raw, adjusted, leverage, efficiency, basis[] }` with `basis` the reasons a
309
+ * person reads ("60 % of its tasks ran on a 0.3-quality engine").
310
+ */
311
+ export function adjustSummary(summary, { qualityOf = () => null, costOf = () => null, reference = 0.6, k = 0.3, bar = 0.5 } = {}) {
312
+ const raw = summary?.rating?.avg ?? null;
313
+ const rows = (summary?.byEngine || []).filter((r) => r.key);
314
+ const known = rows.map((r) => ({ ...r, quality: qualityOf(r.key) })).filter((r) => Number.isFinite(r.quality));
315
+ const totalTasks = known.reduce((n, r) => n + r.tasks, 0);
316
+ const basis = [];
317
+ if (raw == null || !known.length || !totalTasks) return { raw, adjusted: raw, leverage: null, efficiency: null, basis: raw == null ? ['not rated yet'] : ['engines not rated yet — raw rating used'] };
318
+ // Correction: how far below the reference the engines it ran on sit, task-weighted.
319
+ const correction = k * known.reduce((s, r) => s + (r.tasks / totalTasks) * (reference - r.quality), 0);
320
+ const adjusted = Math.max(0, Math.min(1, raw + correction));
321
+ const weak = known.filter((r) => r.quality < reference);
322
+ if (weak.length) basis.push(`${Math.round((weak.reduce((n, r) => n + r.tasks, 0) / totalTasks) * 100)} % of its tasks ran on ${weak.length === 1 ? `a ${weak[0].quality}-quality engine` : 'engines below the reference'}`);
323
+ const strong = known.filter((r) => r.quality > reference);
324
+ if (strong.length && !weak.length) basis.push(`ran on engines above the reference (${strong.map((r) => r.quality).join(', ')})`);
325
+ // Leverage over the engines it was rated on.
326
+ const rated = known.filter((r) => r.rating?.avg != null);
327
+ const ratedTasks = rated.reduce((n, r) => n + r.rating.count, 0);
328
+ const leverage = ratedTasks ? r3(rated.reduce((s, r) => s + (r.rating.count / ratedTasks) * (r.rating.avg - r.quality), 0)) : null;
329
+ if (leverage != null) basis.push(`${leverage >= 0 ? '+' : ''}${leverage} over its engines' own quality`);
330
+ // Efficiency on the cheapest engine that cleared the bar.
331
+ const cleared = rated.filter((r) => r.rating.avg >= bar).map((r) => ({ ...r, cost: costOf(r.key) ?? (r.tokens || null) })).filter((r) => r.cost != null && r.cost > 0).sort((a, b) => a.cost - b.cost);
332
+ const efficiency = cleared.length ? { value: r3(adjusted / cleared[0].cost), engine: cleared[0].key, costPerTask: cleared[0].cost } : null;
333
+ return { raw: r3(raw), adjusted: r3(adjusted), leverage, efficiency, basis };
334
+ }
335
+
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,9 +356,19 @@ 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 } : {}) });
362
+ // The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
363
+ // in which role — produced here, attested by the store, never written by the agent.
364
+ if (status === 'ok' || status === 'failed') {
365
+ say('task.scored', {
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',
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 },
368
+ roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
369
+ refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
370
+ });
371
+ }
333
372
  // The spend so far, after every task — a ledger reads it live instead of at the end.
334
373
  say('run.usage', { usage: budget.snapshot() });
335
374
  if (budget.exhausted()) overBudget = true;
@@ -373,11 +412,15 @@ export async function runTeam({
373
412
  let res = null;
374
413
  const excl = new Set();
375
414
  let judgeErr = '';
415
+ let judgeModel = null; // the appointment that answered (or the last one tried)
416
+ let judgeRoute = null;
376
417
  for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
377
418
  const mm = attempt === 1 ? (runExclude.has(m?.model) ? modelFor(judge, excluding(excl)) : m) : modelFor(judge, excluding(excl));
378
419
  if (!mm?.model) break;
379
420
  if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl], error: judgeErr });
380
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 });
381
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 }) });
382
425
  if (res?.usage) budget.charge(res.usage);
383
426
  if (res?.ok && String(res.text || '').trim()) break;
@@ -387,6 +430,9 @@ export async function runTeam({
387
430
  }
388
431
  const judged = res?.ok && String(res.text || '').trim();
389
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 });
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}`] });
390
436
  say('run.usage', { usage: budget.snapshot() });
391
437
  proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
392
438
  } else {
@@ -421,6 +467,9 @@ export function resumeTeam({ checkpoint, ...deps } = {}) {
421
467
  return runTeam({ ...deps, resume: checkpoint });
422
468
  }
423
469
 
470
+ const lastModelOf = (attempts) => (attempts || []).at(-1)?.model || null;
471
+ const toolNamesOf = (transcript) => [...new Set((transcript || []).flatMap((m) => (m.role === 'assistant' && Array.isArray(m.tool_calls) ? m.tool_calls.map((c) => c.function?.name).filter(Boolean) : [])))];
472
+
424
473
  /** No model: the members' work side by side, findings first — always available. */
425
474
  function mergeCheap(team, findings, tasks) {
426
475
  const sections = tasks.filter((x) => x.status === 'ok').map((x) => {
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
@@ -18,6 +18,11 @@ export function teamLine(ev) {
18
18
  case 'task.note': return { type: 'status', text: `${role}: ${ev.text}` };
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
+ 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;
21
26
  case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
22
27
  case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
23
28
  case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
@@ -39,6 +44,8 @@ export function teamLanes(prev, ev) {
39
44
  case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
40
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;
41
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;
42
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;
43
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;
44
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;