@chatpanel/gateway 0.6.87 → 0.6.91

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/src/scorecard.js CHANGED
@@ -14,11 +14,59 @@
14
14
  // against a job's needs with that record — the same function the evaluator starts from, so
15
15
  // an application's fit has reasons a person can read and overrule.
16
16
  //
17
+ // The model is a variable, not a constant (architecture-pillars.md §13): every task fact also
18
+ // says which ENGINE did it — a model endpoint or a harness (a CLI coding agent), and for a
19
+ // harness the model it was asked to run — so `summarize` can split the card by engine
20
+ // (`byEngine`) and a recruiter can tell an agent that did well on a small model from one
21
+ // that was carried by a large one. And where the task ran in a git checkout (§14), the fact
22
+ // carries `scm`: the branch and HEAD before and after, commits made, a PR when one was
23
+ // opened, and whether it merged — the one outcome that does not come from a judge.
24
+ //
17
25
  // Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
18
26
 
19
27
  export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
20
28
  export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
21
29
  export const SCORECARD_VERSION = 1;
30
+ export const ENGINE_KINDS = Object.freeze(['model', 'harness']);
31
+
32
+ /**
33
+ * An engine as the record keeps it: `{ kind, id, model?, label? }`. `kind` is `model` (an
34
+ * endpoint the client calls) or `harness` (a CLI coding agent the bridge runs — `id` is the
35
+ * harness, `model` the model it was asked to run, when one was named). A host that does not
36
+ * say the kind gets `model`, which is the honest default for a bare model id. A string is
37
+ * an id.
38
+ */
39
+ export function normalizeEngine(e) {
40
+ if (!e) return null;
41
+ const src = typeof e === 'string' ? { id: e } : e;
42
+ const id = String(src.id || src.harnessId || src.model || '').trim();
43
+ if (!id) return null;
44
+ const kind = ENGINE_KINDS.includes(src.kind) ? src.kind : (src.harnessId ? 'harness' : 'model');
45
+ const model = src.model != null && String(src.model).trim() && String(src.model) !== id ? String(src.model).trim() : undefined;
46
+ return { kind, id, ...(model ? { model } : {}), ...(src.label && String(src.label) !== id ? { label: String(src.label).slice(0, 120) } : {}) };
47
+ }
48
+
49
+ /** One key per engine — what `byEngine` groups on and what the model ledger will be keyed by. */
50
+ export function engineKey(e) {
51
+ const n = normalizeEngine(e);
52
+ return n ? `${n.kind}:${n.id}${n.model ? `/${n.model}` : ''}` : null;
53
+ }
54
+
55
+ /** What a task did in a checkout, as the record keeps it. Strings clipped, counts rounded. */
56
+ export function normalizeScm(s) {
57
+ if (!s || typeof s !== 'object') return null;
58
+ const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).slice(0, n));
59
+ const out = {
60
+ repo: str(s.repo, 300), remote: str(s.remote, 300), base: str(s.base, 120), branch: str(s.branch, 120),
61
+ head: str(s.head, 64), headAfter: str(s.headAfter, 64),
62
+ commits: s.commits != null ? Math.max(0, Math.round(Number(s.commits) || 0)) : undefined,
63
+ pr: str(s.pr, 300),
64
+ merged: s.merged === true ? true : s.merged === false ? false : undefined,
65
+ dirty: s.dirty === true ? true : s.dirty === false ? false : undefined,
66
+ };
67
+ for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
68
+ return Object.keys(out).length ? out : null;
69
+ }
22
70
 
23
71
  const enc = new TextEncoder();
24
72
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
@@ -61,6 +109,8 @@ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } =
61
109
  ...(fact.runId ? { runId: String(fact.runId) } : {}),
62
110
  ...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
63
111
  ...(fact.model ? { model: String(fact.model) } : {}),
112
+ ...(normalizeEngine(fact.engine) ? { engine: normalizeEngine(fact.engine) } : {}),
113
+ ...(normalizeScm(fact.scm) ? { scm: normalizeScm(fact.scm) } : {}),
64
114
  ...(fact.size ? { size: sizeOf(fact.size) } : {}),
65
115
  ...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
66
116
  ...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
@@ -76,6 +126,7 @@ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } =
76
126
  }
77
127
 
78
128
  const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
129
+ const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
79
130
  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)) });
80
131
 
81
132
  /** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
@@ -121,13 +172,53 @@ export function summarize(entries, { recent = 5 } = {}) {
121
172
  const tools = new Set(); const withAgents = new Set(); const created = new Set();
122
173
  const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
123
174
  const models = new Map();
175
+ // Per engine: how many tasks, how they went, how big, and the ratings that were ABOUT a
176
+ // task on it. A rating names its task by `about` (the seq of the entry it rates), by
177
+ // taskId, or — failing both — by runId when that run had exactly one task on the card.
178
+ const engines = new Map(); // key -> { engine, tasks, done, failed, tokens, ratings[] }
179
+ const engineOfEntry = new Map(); // seq -> key
180
+ const byTask = new Map(); const byRun = new Map(); // taskId -> seq, runId -> [seq]
181
+ const scm = { tasks: 0, commits: 0, prs: 0, merged: 0 };
124
182
  for (const e of list) {
125
183
  for (const t of e.tools || []) tools.add(t);
126
184
  for (const a of e.with || []) withAgents.add(a);
127
185
  for (const a of e.created || []) created.add(a);
128
186
  if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
129
187
  if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
188
+ if (e.kind === 'task.done' || e.kind === 'task.failed') {
189
+ const key = engineKey(e.engine);
190
+ if (key) {
191
+ const row = engines.get(key) || { engine: normalizeEngine(e.engine), tasks: 0, done: 0, failed: 0, tokens: 0, ratings: [] };
192
+ row.tasks += 1; row[e.kind === 'task.done' ? 'done' : 'failed'] += 1; row.tokens += e.size?.tokens || 0;
193
+ engines.set(key, row);
194
+ engineOfEntry.set(e.seq, key);
195
+ if (e.taskId) byTask.set(`${e.runId || ''}/${e.taskId}`, e.seq);
196
+ if (e.runId) byRun.set(e.runId, [...(byRun.get(e.runId) || []), e.seq]);
197
+ }
198
+ 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; }
199
+ }
200
+ }
201
+ for (const e of list) {
202
+ if (e.kind !== 'rating' || !e.rating) continue;
203
+ const seq = e.rating.about != null ? e.rating.about
204
+ : e.taskId && byTask.has(`${e.runId || ''}/${e.taskId}`) ? byTask.get(`${e.runId || ''}/${e.taskId}`)
205
+ : e.runId && (byRun.get(e.runId) || []).length === 1 ? byRun.get(e.runId)[0] : null;
206
+ const key = seq != null ? engineOfEntry.get(seq) : null;
207
+ if (key) engines.get(key).ratings.push(e.rating.score);
130
208
  }
209
+ const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
210
+ const byEngine = [...engines.values()].sort((a, b) => b.tasks - a.tasks).map((r) => ({
211
+ key: engineKey(r.engine), ...r.engine, tasks: r.tasks, done: r.done, failed: r.failed,
212
+ failRate: r.tasks ? Math.round((r.failed / r.tasks) * 1000) / 1000 : 0,
213
+ tokens: r.tasks ? Math.round(r.tokens / r.tasks) : 0, // mean per task — the cost proxy until the ledger prices it
214
+ rating: { avg: mean(r.ratings), count: r.ratings.length },
215
+ }));
216
+ // 1 − spread of rating across engines it was rated on (≥ 2): low spread = robust to routing;
217
+ // high spread = it NEEDS a particular engine, a fact a recruiter respects, not a penalty.
218
+ const ratedEngines = byEngine.filter((r) => r.rating.avg != null).map((r) => r.rating.avg);
219
+ const engineIndependence = ratedEngines.length >= 2 ? Math.round((1 - (Math.max(...ratedEngines) - Math.min(...ratedEngines))) * 1000) / 1000 : null;
220
+ // Leverage (rating above the engine's own mean) and efficiency (rating ÷ cost) wait on the
221
+ // model ledger (§13.2, with A1): they need every engine's mean, which one card cannot know.
131
222
  const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
132
223
  const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
133
224
  const recentRatings = ratings.slice(-recent);
@@ -143,6 +234,9 @@ export function summarize(entries, { recent = 5 } = {}) {
143
234
  created: [...created],
144
235
  roles,
145
236
  models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
237
+ byEngine,
238
+ engineIndependence,
239
+ scm,
146
240
  rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
147
241
  refs,
148
242
  since: list[0]?.at || null,
@@ -157,7 +251,7 @@ export function summarize(entries, { recent = 5 } = {}) {
157
251
  * summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
158
252
  * without the tools cannot do the job), track record second, size third.
159
253
  */
160
- export function fit(job, type, summary = null) {
254
+ export function fit(job, type, summary = null, { qualityOf = null, costOf = null, adjust = true } = {}) {
161
255
  const needs = job?.needs || {};
162
256
  const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
163
257
  const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
@@ -175,11 +269,18 @@ export function fit(job, type, summary = null) {
175
269
  const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
176
270
  if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
177
271
  let record = 0.5; // a fresh type is neither trusted nor distrusted
272
+ let adjusted = null;
178
273
  if (summary && summary.entries) {
179
274
  const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
180
- const rated = summary.rating.avg == null ? 0.5 : summary.rating.avg;
275
+ // The track record uses the MODEL-ADJUSTED rating when the caller can say what the
276
+ // engines were worth (§13.3): an agent rated 0.82 mostly on a weak engine ranks above
277
+ // one rated 0.82 on a frontier model. The reasons say so, and a person can turn it off.
278
+ adjusted = adjust && qualityOf && summary.rating.avg != null ? adjustSummary(summary, { qualityOf, costOf: costOf || undefined }) : null;
279
+ const rated = summary.rating.avg == null ? 0.5 : (adjusted?.adjusted ?? summary.rating.avg);
181
280
  record = doneRate * 0.5 + rated * 0.5;
182
- reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? `, rated ${Math.round(summary.rating.avg * 100)}%` : ''}`);
281
+ 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)}%`) : ''}`);
282
+ if (adjusted?.leverage != null && adjusted.leverage > 0.05) reasons.push(`adds ${adjusted.leverage} over its engines' own quality`);
283
+ if (adjusted?.efficiency) reasons.push(`cleared the bar cheapest on ${adjusted.efficiency.engine}`);
183
284
  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`);
184
285
  } else {
185
286
  reasons.push('no record yet');
@@ -188,5 +289,48 @@ export function fit(job, type, summary = null) {
188
289
  const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
189
290
  if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
190
291
  const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
191
- return { score, reasons, parts: { needs: needScore, record, size: sizeScore } };
292
+ return { score, reasons, parts: { needs: needScore, record, size: sizeScore }, ...(adjusted ? { adjusted } : {}) };
192
293
  }
294
+
295
+ // ── Agent scores, normalised by engine (§13.3) — what the model ledger's cards make possible ──
296
+
297
+ /**
298
+ * The model-adjusted view of an agent's card (scorecard.js `summarize()`), given what its
299
+ * engines are worth: `qualityOf(key)` → the engine's quality in [0, 1] (the card's mean
300
+ * rating for the job kind when observed, else the router's guess) or null when unknown.
301
+ *
302
+ * leverage rating on an engine minus that engine's quality, weighted by tasks — what
303
+ * the agent's prompt and tools add that the model does not supply on its own
304
+ * adjusted the raw rating corrected for the engines it ran on: work done on a weak
305
+ * engine counts for more, on a strong one for less; `k` bounds the correction
306
+ * efficiency adjusted rating ÷ cost per task on the cheapest engine that cleared `bar`
307
+ * (`costOf(key)` → $/task or a token proxy; null when nothing is priced)
308
+ *
309
+ * Returns `{ raw, adjusted, leverage, efficiency, basis[] }` with `basis` the reasons a
310
+ * person reads ("60 % of its tasks ran on a 0.3-quality engine").
311
+ */
312
+ export function adjustSummary(summary, { qualityOf = () => null, costOf = () => null, reference = 0.6, k = 0.3, bar = 0.5 } = {}) {
313
+ const raw = summary?.rating?.avg ?? null;
314
+ const rows = (summary?.byEngine || []).filter((r) => r.key);
315
+ const known = rows.map((r) => ({ ...r, quality: qualityOf(r.key) })).filter((r) => Number.isFinite(r.quality));
316
+ const totalTasks = known.reduce((n, r) => n + r.tasks, 0);
317
+ const basis = [];
318
+ 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'] };
319
+ // Correction: how far below the reference the engines it ran on sit, task-weighted.
320
+ const correction = k * known.reduce((s, r) => s + (r.tasks / totalTasks) * (reference - r.quality), 0);
321
+ const adjusted = Math.max(0, Math.min(1, raw + correction));
322
+ const weak = known.filter((r) => r.quality < reference);
323
+ 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'}`);
324
+ const strong = known.filter((r) => r.quality > reference);
325
+ if (strong.length && !weak.length) basis.push(`ran on engines above the reference (${strong.map((r) => r.quality).join(', ')})`);
326
+ // Leverage over the engines it was rated on.
327
+ const rated = known.filter((r) => r.rating?.avg != null);
328
+ const ratedTasks = rated.reduce((n, r) => n + r.rating.count, 0);
329
+ const leverage = ratedTasks ? r3(rated.reduce((s, r) => s + (r.rating.count / ratedTasks) * (r.rating.avg - r.quality), 0)) : null;
330
+ if (leverage != null) basis.push(`${leverage >= 0 ? '+' : ''}${leverage} over its engines' own quality`);
331
+ // Efficiency on the cheapest engine that cleared the bar.
332
+ 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);
333
+ const efficiency = cleared.length ? { value: r3(adjusted / cleared[0].cost), engine: cleared[0].key, costPerTask: cleared[0].cost } : null;
334
+ return { raw: r3(raw), adjusted: r3(adjusted), leverage, efficiency, basis };
335
+ }
336
+
package/src/server.js CHANGED
@@ -36,6 +36,9 @@ import { createMemoryStore } from './memory-store.js';
36
36
  import { createPrefsStore } from './prefs-store.js';
37
37
  import { createTeamStore, loadOrCreateKey as loadTeamKey } from './team-store.js';
38
38
  import { createScorecardStore } from './scorecard-store.js';
39
+ import { createEngineLedgerStore } from './engine-ledger-store.js';
40
+ import { createProjectStore } from './project-store.js';
41
+ import { applicationsFor, recruitPass } from './recruiting.js';
39
42
  import { createHistoryStore } from './sqlite-store.js';
40
43
  import { ingestBackups } from './backup-ingest.js';
41
44
  import * as nerEngine from './ner-engine.js';
@@ -59,7 +62,7 @@ import * as openai from './openai.js';
59
62
  import * as responses from './responses.js';
60
63
  import * as anthropic from './anthropic.js';
61
64
 
62
- export const VERSION = '0.6.87';
65
+ export const VERSION = '0.6.91';
63
66
 
64
67
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
65
68
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -69,7 +72,9 @@ const historyStore = await createHistoryStore();
69
72
  const memoryStore = await createMemoryStore();
70
73
  const prefsStore = createPrefsStore();
71
74
  const scorecards = createScorecardStore({ key: loadTeamKey() });
72
- const teamStore = createTeamStore({ scorecards });
75
+ const engines = createEngineLedgerStore({ key: loadTeamKey() });
76
+ const projectStore = createProjectStore({ key: loadTeamKey() });
77
+ const teamStore = createTeamStore({ scorecards, engines });
73
78
  // Who is watching prefs change — a client with a live subscription is told the moment a
74
79
  // section is written by the other client, instead of waiting for its next focus.
75
80
  const prefsWatchers = new Set();
@@ -165,6 +170,12 @@ function fmtTimings(t) {
165
170
  // restore model output → harness[restore] → user response (non-stream; for
166
171
  // streams restore is inline per chunk, so it's folded into stream)
167
172
  // total end-to-end through the gateway
173
+ /** `model:<id>[/<model>]` / `harness:<id>[/<model>]` → the record's engine, or null. */
174
+ function engineFromKey(key) {
175
+ const m = /^(model|harness):([^/]+)(?:\/(.+))?$/.exec(String(key || ''));
176
+ return m ? { kind: m[1], id: m[2], ...(m[3] ? { model: m[3] } : {}) } : null;
177
+ }
178
+
168
179
  function mkTrace(sink) {
169
180
  const start = performance.now();
170
181
  const timings = {};
@@ -397,8 +408,12 @@ async function pumpRelay(res, s, shaper, trace) {
397
408
  }
398
409
 
399
410
  // New tool-enabled turn: open the bridge with the client's tools as MCP specs.
400
- async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
411
+ async function startRelay(req, res, { kind, adapter, agent, run = null }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
401
412
  const { messages, system } = adapter.toTurn(body);
413
+ // The model half of `claude/opus` and the team role's run options travel with a
414
+ // tool-using turn the same as with a plain one (handleBridge below).
415
+ const { agentModel } = parseAgentModel(body?.model, cfg);
416
+ const options = bridgeAgentOptions(cfg, { ...(agentModel ? { model: agentModel } : {}), ...(run || {}) });
402
417
  const token = readBridgeToken(cfg.bridge.token);
403
418
  const shaper = shaperFor(kind, body?.model || agent);
404
419
  // Full tier for everyone here (the free allowance is enforced in the main
@@ -412,7 +427,7 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
412
427
  // redaction in the main handler), so toTurn() carried it here — nothing to add.
413
428
  let resp;
414
429
  try {
415
- resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options: bridgeAgentOptions(cfg), signal: undefined });
430
+ resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options, signal: undefined });
416
431
  } catch (e) { endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
417
432
  s.reader = resp.body.getReader();
418
433
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
@@ -436,7 +451,7 @@ async function resumeRelay(res, s, toolContent, model, trace = null) {
436
451
  return pumpRelay(res, s, shaper, trace);
437
452
  }
438
453
 
439
- async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace }, body, vault, cfg, isPro) {
454
+ async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace, run = null }, body, vault, cfg, isPro) {
440
455
  if (!redactable) {
441
456
  trace?.commit();
442
457
  return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
@@ -455,7 +470,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
455
470
  }
456
471
  const tools = adapter.extractTools(body);
457
472
  if (tools.length && body?.stream === true) {
458
- return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness, trace);
473
+ return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg), run }, body, vault, cfg, isPro, tools, harness, trace);
459
474
  }
460
475
  }
461
476
 
@@ -481,7 +496,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
481
496
  bridgeUrl: await resolveBridgeUrl(cfg), agent, token, messages, system, signal: ac.signal,
482
497
  // Permissions and working directory from the gateway's config (the desktop's Settings →
483
498
  // Engine → Agents), plus the model half of `claude/opus` when the caller named one.
484
- options: bridgeAgentOptions(cfg, agentModel ? { model: agentModel } : {}),
499
+ options: bridgeAgentOptions(cfg, { ...(agentModel ? { model: agentModel } : {}), ...(run || {}) }),
485
500
  };
486
501
 
487
502
  if (!wantStream) {
@@ -708,7 +723,7 @@ export function createGateway(cfg = loadConfig()) {
708
723
  // Client preferences travel between the extension and the desktop through here, and an
709
724
  // MCP server entry can carry an Authorization header — so READS are gated too, unlike
710
725
  // history and memory. A drive-by page must not learn what tools the user connected.
711
- if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams') || pathname.startsWith('/v1/agents')) && !isAdminAuthorized(req)) {
726
+ if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams') || pathname.startsWith('/v1/agents') || pathname.startsWith('/v1/projects')) && !isAdminAuthorized(req)) {
712
727
  return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
713
728
  }
714
729
  // The access log is who-read-what — sensitive, and writable only by the local MCP
@@ -822,6 +837,87 @@ export function createGateway(cfg = loadConfig()) {
822
837
  return undefined;
823
838
  }
824
839
 
840
+ // --- PROJECTS. The page a goal starts on and everything done for it (project-store.js):
841
+ // GET /v1/projects[?limit&status] → { ok, projects } newest activity first, jobs counted
842
+ // POST /v1/projects { id, project, by } → { ok, project } open a record (idempotent) / update the page
843
+ // GET /v1/projects/jobs → { ok, jobs } the job board: every open posting across projects
844
+ // GET /v1/projects/:id[?events=1] → { ok, project } the record: jobs, runs, spend, decisions, report
845
+ // POST /v1/projects/:id/events { events } → { ok, project } the executive loop appends (status, run.linked, run.spent, decision, report)
846
+ // POST /v1/projects/:id/jobs { job, by } → { ok, project } post a job
847
+ // POST /v1/projects/:id/jobs/:jobId { patch, by } → { ok, project } move it along its machine / applications / recruited / result
848
+ // GET /v1/projects/:id/jobs/:jobId/applications[?reach&chatModel] → { ok, job, applications, prompt, rows }
849
+ // the pool applies at once (recruiting.js); `prompt` is the evaluator's, for the client's structured call
850
+ // POST /v1/projects/:id/jobs/:jobId/recruit { by, reach, chatModel, evaluation? | text? } → { ok, project, decision, applications }
851
+ // one pass: fit recomputed here, the client's evaluation read through the schema, the pick (or the
852
+ // proposal) landed as events — with no evaluation the fit decides
853
+ // GET /v1/projects/:id/events[?after] (SSE) hello, replay, then live
854
+ // DELETE /v1/projects/:id
855
+ if (pathname === '/v1/projects' && req.method === 'GET') return sendJson(res, 200, { ok: true, projects: projectStore.list({ limit: url.searchParams.get('limit') || 50, status: url.searchParams.get('status') || '' }) });
856
+ if (pathname === '/v1/projects/jobs' && req.method === 'GET') return sendJson(res, 200, { ok: true, jobs: projectStore.openJobs() });
857
+ if (pathname === '/v1/projects' && req.method === 'POST') {
858
+ try {
859
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
860
+ return sendJson(res, 200, { ok: true, project: projectStore.create({ id: body.id || body.project?.id, project: body.project || null, by: body.by }) });
861
+ } catch (e) { return sendJson(res, 400, { error: { message: `project: ${e.message}`, type: 'project_error' } }); }
862
+ }
863
+ {
864
+ const m = /^\/v1\/projects\/([a-zA-Z0-9_-]{1,64})(\/events|\/jobs(?:\/([a-zA-Z0-9_-]{1,64})(\/applications|\/recruit)?)?)?$/.exec(pathname);
865
+ if (m) {
866
+ const id = m[1]; const sub = m[2] || ''; const jobId = m[3] || ''; const act = m[4] || '';
867
+ const notFound = () => sendJson(res, 404, { error: { message: `no project ${id}`, type: 'not_found' } });
868
+ if (act) {
869
+ const rec = projectStore.get(id);
870
+ if (!rec) return notFound();
871
+ const job = rec.jobs.find((j) => j.id === jobId);
872
+ if (!job) return sendJson(res, 404, { error: { message: `no job ${jobId}`, type: 'not_found' } });
873
+ const stores = { cfg, prefsStore, scorecards, engines };
874
+ if (act === '/applications' && req.method === 'GET') {
875
+ const out = await applicationsFor(job, { ...stores, reach: url.searchParams.get('reach') || 'any', chatModel: url.searchParams.get('chatModel') || null });
876
+ return sendJson(res, 200, { ok: true, job, ...out });
877
+ }
878
+ if (act === '/recruit' && req.method === 'POST') {
879
+ if (!['open', 'evaluating'].includes(job.status)) return sendJson(res, 400, { error: { message: `job ${jobId} is ${job.status}; only an open job is recruited`, type: 'project_error' } });
880
+ try {
881
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
882
+ const by = String(body.by || 'evaluator').slice(0, 80);
883
+ const pass = await recruitPass(job, { ...stores, record: rec, reach: body.reach || 'any', chatModel: body.chatModel || null, evaluation: body.evaluation || null, text: body.text ?? null, by });
884
+ // Through the store's own moves, so the job's machine is checked on every step.
885
+ let project = null;
886
+ for (const e of pass.events) {
887
+ if (e.type === 'job.updated') project = projectStore.updateJob(id, jobId, e.job, { by: e.by || by });
888
+ else project = projectStore.append(id, [e]);
889
+ }
890
+ return sendJson(res, 200, { ok: true, project, decision: pass.decision, applications: pass.applications, evaluation: pass.evaluation });
891
+ } catch (e) { return sendJson(res, 400, { error: { message: `recruit: ${e.message}`, type: 'project_error' } }); }
892
+ }
893
+ return sendJson(res, 405, { error: { message: 'method not allowed', type: 'project_error' } });
894
+ }
895
+ if (!sub && req.method === 'GET') { const p = projectStore.get(id, { events: url.searchParams.get('events') === '1' }); return p ? sendJson(res, 200, { ok: true, project: p }) : notFound(); }
896
+ if (!sub && req.method === 'DELETE') return sendJson(res, 200, { ok: true, removed: projectStore.remove(id) });
897
+ if (req.method === 'POST' && (sub === '/events' || sub.startsWith('/jobs'))) {
898
+ try {
899
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
900
+ const by = String(body.by || 'person').slice(0, 80);
901
+ const project = sub === '/events' ? projectStore.append(id, body.events || [])
902
+ : jobId ? projectStore.updateJob(id, jobId, body.patch || body, { by })
903
+ : projectStore.postJob(id, body.job || body, { by });
904
+ return sendJson(res, 200, { ok: true, project });
905
+ } catch (e) { return sendJson(res, e.message.startsWith('no ') ? 404 : 400, { error: { message: `project: ${e.message}`, type: 'project_error' } }); }
906
+ }
907
+ if (sub === '/events' && req.method === 'GET') {
908
+ if (!projectStore.get(id)) return notFound();
909
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
910
+ const sendEv = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
911
+ let last = url.searchParams.has('after') ? Number(url.searchParams.get('after')) : -1;
912
+ sendEv({ seq: -1, type: 'hello', at: Date.now(), payload: { project: projectStore.get(id), after: last } });
913
+ const off = projectStore.watch(id, (ev) => { if (ev.seq > last) { last = ev.seq; sendEv(ev); } });
914
+ for (const ev of projectStore.eventsSince(id, last)) { last = ev.seq; sendEv(ev); }
915
+ const beat = setInterval(() => { try { res.write(': keep\n\n'); } catch { /* closed */ } }, 25_000);
916
+ req.on('close', () => { clearInterval(beat); off(); });
917
+ return undefined;
918
+ }
919
+ }
920
+ }
825
921
  // --- SCORECARDS. Every agent's attested record (scorecard-store.js): the chain, its card,
826
922
  // whether it verifies; a person's rating appended from either client.
827
923
  if (pathname === '/v1/agents/scorecards' && req.method === 'GET') return sendJson(res, 200, { ok: true, agents: scorecards.list() });
@@ -834,11 +930,43 @@ export function createGateway(cfg = loadConfig()) {
834
930
  try {
835
931
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
836
932
  const entry = await scorecards.append({ agentId, kind: 'rating', runId: body.runId, taskId: body.taskId, jobId: body.jobId, rating: { by: String(body.by || 'person').slice(0, 40), score: body.score, note: body.note, about: body.about }, refs: body.refs });
933
+ // The verdict is the engine's too: it lands on the ledger of what served the task.
934
+ engines.fromRating(scorecards.chains.get(agentId) || [], { ...entry, jobKind: body.jobKind ? String(body.jobKind).slice(0, 60) : undefined });
837
935
  return sendJson(res, 200, { ok: true, entry });
838
936
  } catch (e) { return sendJson(res, 400, { error: { message: `scorecard: ${e.message}`, type: 'scorecard_error' } }); }
839
937
  }
840
938
  }
841
939
  }
940
+ // --- ENGINES. Every model's / harness's attested ledger (engine-ledger-store.js): the
941
+ // card a client feeds applyCard, the chain on request; a host's observed call, a
942
+ // person's price or capability proof appended from either client.
943
+ // GET /v1/engines[?minCalls] → { ok, engines: [card] }
944
+ // GET /v1/engines/:key/card[?entries=1] → { ok, key, card, entries?, verified?, attested? }
945
+ // POST /v1/engines/:key/entries { kind, engine, call|rating|price|capability|declined, … }
946
+ if (pathname === '/v1/engines' && req.method === 'GET') {
947
+ const minCalls = Number(url.searchParams.get('minCalls')) || undefined;
948
+ return sendJson(res, 200, { ok: true, engines: engines.list({ minCalls }) });
949
+ }
950
+ {
951
+ const m = /^\/v1\/engines\/(.+)\/(card|entries)$/.exec(pathname);
952
+ if (m) {
953
+ const key = decodeURIComponent(m[1]).slice(0, 300);
954
+ if (m[2] === 'card' && req.method === 'GET') {
955
+ const minCalls = Number(url.searchParams.get('minCalls')) || undefined;
956
+ return sendJson(res, 200, { ok: true, ...(await engines.get(key, { entries: url.searchParams.get('entries') === '1', minCalls })) });
957
+ }
958
+ if (m[2] === 'entries' && req.method === 'POST') {
959
+ try {
960
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
961
+ const engine = body.engine || engineFromKey(key);
962
+ if (!engine) return sendJson(res, 400, { error: { message: 'model-ledger: engine required', type: 'model_ledger_error' } });
963
+ const entry = await engines.append({ ...body, engine, kind: body.kind || 'call' });
964
+ if (entry.key !== key) return sendJson(res, 400, { error: { message: `model-ledger: entry is for ${entry.key}, not ${key}`, type: 'model_ledger_error' } });
965
+ return sendJson(res, 200, { ok: true, entry });
966
+ } catch (e) { return sendJson(res, 400, { error: { message: `model-ledger: ${e.message}`, type: 'model_ledger_error' } }); }
967
+ }
968
+ }
969
+ }
842
970
  // --- TEAM RUNS. The board every client can read (team-store.js).
843
971
  // GET /v1/teams/runs[?limit&team] → { ok, runs } newest first, no boards
844
972
  // POST /v1/teams/runs { id, team, request, client } → { ok, run }
@@ -2031,6 +2159,10 @@ export function createGateway(cfg = loadConfig()) {
2031
2159
  const hint = {
2032
2160
  destination: String(req.headers['x-chatpanel-destination'] || legacy?.destination || '').trim(),
2033
2161
  reach: String(req.headers['x-chatpanel-reach'] || legacy?.reach || '').trim(),
2162
+ // A TEAM ROLE'S RUN, for the bridge (pillars §14.2): `{ grants, workspace, connectionId }`
2163
+ // — URI-encoded JSON in a header (the body belongs to the provider), the legacy body
2164
+ // field also honoured. Only the bridge path reads it; an API destination never sees it.
2165
+ run: readRunHint(req.headers['x-chatpanel-run'], legacy?.run),
2034
2166
  };
2035
2167
  const dest = resolveDestination(body?.model, cfg, r.kind, { destination: hint.destination });
2036
2168
  // An EXPLICIT destination that does not resolve is an error, not an invitation to fall
@@ -2057,10 +2189,25 @@ export function createGateway(cfg = loadConfig()) {
2057
2189
  }
2058
2190
  return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness, trace }, outBody, vault);
2059
2191
  }
2060
- return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace }, body, vault, cfg, isPro);
2192
+ return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace, run: hint.run }, body, vault, cfg, isPro);
2061
2193
  });
2062
2194
  }
2063
2195
 
2196
+ /** `{ grants[], workspace{ repo, projectId, jobId, base? }, connectionId }` from the header or the legacy field — shaped, never a token. */
2197
+ function readRunHint(header, legacy) {
2198
+ let raw = legacy && typeof legacy === 'object' ? legacy : null;
2199
+ if (!raw && header) { try { raw = JSON.parse(decodeURIComponent(String(header))); } catch { raw = null; } }
2200
+ if (!raw || typeof raw !== 'object') return null;
2201
+ const out = {};
2202
+ if (Array.isArray(raw.grants)) out.grants = raw.grants.map((g) => String(g).slice(0, 80)).slice(0, 32);
2203
+ if (raw.workspace && typeof raw.workspace === 'object' && raw.workspace.repo) {
2204
+ const w = raw.workspace;
2205
+ out.workspace = { repo: String(w.repo).slice(0, 400), projectId: String(w.projectId || '').slice(0, 120), jobId: String(w.jobId || '').slice(0, 120), ...(w.base ? { base: String(w.base).slice(0, 120) } : {}) };
2206
+ }
2207
+ if (raw.connectionId) out.connectionId = String(raw.connectionId).slice(0, 64);
2208
+ return Object.keys(out).length ? out : null;
2209
+ }
2210
+
2064
2211
  export function start(cfg = loadConfig()) {
2065
2212
  installTimestampedConsole(); // every gateway log line gets a clock — before anything logs
2066
2213
  ensureGatewayToken(); // M2: load/create the admin-route token (best-effort)
package/src/team-store.js CHANGED
@@ -58,8 +58,9 @@ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)
58
58
  export function applyEvent(run, ev) { return foldRun(run, ev); }
59
59
 
60
60
  export class TeamStore {
61
- constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS, scorecards = null } = {}) {
61
+ constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS, scorecards = null, engines = null } = {}) {
62
62
  this.scorecards = scorecards; // the agents' ledgers (scorecard-store.js), fed by task.scored
63
+ this.engines = engines; // the engines' ledgers (engine-ledger-store.js), fed by task.routed / reappointed / handoff / scored
63
64
  this.path = storePath;
64
65
  this.now = now;
65
66
  this.staleAfterMs = staleAfterMs;
@@ -134,6 +135,8 @@ export class TeamStore {
134
135
  applyEvent(run, ev);
135
136
  // A finished task's fact goes to the member's scorecard — chained and attested there.
136
137
  if (this.scorecards && ev.type === 'task.scored') this.scorecards.fromRunEvent(ev, run);
138
+ // …and to the engine's ledger: the call, or the decline / rotation that preceded it.
139
+ if (this.engines && (ev.type === 'task.routed' || ev.type === 'task.reappointed' || ev.type === 'task.handoff' || ev.type === 'task.scored')) this.engines.fromRunEvent(ev, run);
137
140
  for (const fn of this.watchers.get(run.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
138
141
  }
139
142
  this.save();