@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/recruit.js ADDED
@@ -0,0 +1,425 @@
1
+ // VENDORED from @chatpanel/events/recruit.js — edit there, then copy over.
2
+ // Recruiting — applying and evaluating, the step between a posted job and a run (F8 §12.2.4,
3
+ // architecture-pillars.md §13.4).
4
+ //
5
+ // Applications are COMPUTED, not asked for: every agent in the pool applies to every job at
6
+ // once, scored by `fit` (scorecard.js) on its skills, tools, grants and its attested record —
7
+ // the model-ADJUSTED rating when the engine rows say what its engines were worth. Recruiting
8
+ // costs one model turn per job, not one per applicant, and that turn is optional.
9
+ //
10
+ // What is recruited is an (agent, engine) PAIR. An agent's engine may be fixed (`model`,
11
+ // `harness`), the chat's (`assistant`), or `auto` with a policy; `routeFor` resolves it
12
+ // against the ENGINE ROWS the host knows — every model or harness it can run, with reach,
13
+ // capabilities, quality / latency / cost (the engine card's observed values over the router's
14
+ // guess, `engineRow`) and whether it is available right now. Requirements eliminate first
15
+ // (reach from the project's privacy setting — never learned, only typed; a work grant needs a
16
+ // harness; tools need `tools`), the policy orders what survives over observed values, and the
17
+ // agent's own record on each engine breaks ties. An agent whose engine does not clear is
18
+ // still an applicant — a person should see it — but is not recruitable now.
19
+ //
20
+ // The EVALUATOR is one structured call (RECRUIT_SCHEMA) over the top applications: the pick
21
+ // and why, or "none fits" with the agent that should exist. The call is the host's
22
+ // (`runStructured` in a client; a gateway without a model skips it): `decide` takes its parsed
23
+ // answer when there is one and falls back to the best fit above a floor when there is not, so
24
+ // a job is recruited with or without a model. The decision lands on the project record as
25
+ // events (`recruitEvents`) — the pick and the reasons on the job, a proposal as a decision a
26
+ // person reads — never as a mutation.
27
+ //
28
+ // Pure, dependency-free; imports only what the gateway already vendors (scorecard.js,
29
+ // model-ledger.js, engine.js, agent.js, job.js, team.js, structured.js, budget.js).
30
+
31
+ import { fit, engineKey, normalizeEngine } from './scorecard.js';
32
+ import { cardOverride, DEFAULT_MIN_CALLS } from './model-ledger.js';
33
+ import { normalizeEngineSpec, engineKeyOf, describeEngine } from './engine.js';
34
+ import { engineOf, agentFromForm } from './agent.js';
35
+ import { applyAll } from './job.js';
36
+ import { WORK_GRANTS } from './team.js';
37
+ import { defineSchema, describeSchema, coerce } from './structured.js';
38
+ import { normalizeBudget } from './budget.js';
39
+
40
+ export const MIN_FIT = 0.5;
41
+ export const TOP_APPLICANTS = 5;
42
+ export const MAX_BRIEF_IN_PROMPT = 1500;
43
+ const REACH_RANK = { device: 0, trusted: 1, any: 2 };
44
+
45
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
46
+ const r3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000);
47
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
48
+ const lower = (xs) => (Array.isArray(xs) ? xs : []).map((x) => String(x).toLowerCase());
49
+
50
+ // ── The evaluator's answer ────────────────────────────────────────────────────────────────
51
+
52
+ export const RECRUIT_SCHEMA = defineSchema({
53
+ name: 'recruit',
54
+ purpose: 'which applicant gets the job, or that none fits and what agent should exist',
55
+ fields: {
56
+ // Not `required`: an emptied required field reads as "nothing" and would drop the proposal that rides beside it.
57
+ pick: { type: 'string', max: 64, describe: 'the id of the ONE applicant to recruit, or "" when none fits' },
58
+ why: { type: 'string', required: true, max: 400, describe: 'one or two sentences a person reads on the job page' },
59
+ confidence: { type: 'number', describe: '0 to 1' },
60
+ proposalName: { type: 'string', max: 60, describe: 'when none fits: the agent that should exist' },
61
+ proposalPurpose: { type: 'string', max: 300 },
62
+ proposalSkills: { type: 'string[]', maxItems: 12 },
63
+ proposalGrants: { type: 'string[]', maxItems: 8, describe: 'from: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr' },
64
+ },
65
+ nothing: { pick: '', why: 'none fits' },
66
+ });
67
+
68
+ // ── Engine rows: what the host can run, as the recruiter reads it ─────────────────────────
69
+
70
+ /**
71
+ * One row from a host's candidate (a router model — `inferCandidate`'s shape: `id`, `model`,
72
+ * `reach`, `capabilities`, `quality`, `latencyMs`, `costPer1k`, `available`, `classUsed`;
73
+ * or anything carrying an `engine` ref) with its engine CARD applied: observed quality,
74
+ * latency, cost and availability replace the guess where there is enough history, and a
75
+ * withdrawn capability is gone. A bridge agent (`classUsed 'A'` / `kind 'bridge'`) is a
76
+ * harness — `claude/opus` is the harness `claude` asked to run `opus` — keyed the way the
77
+ * desktop's appointer and the engine ledger key it.
78
+ */
79
+ export function engineRow(candidate, { card = null, minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
80
+ if (!isRecord(candidate)) return null;
81
+ const c = candidate;
82
+ let engine = c.engine ? normalizeEngine(c.engine) : null;
83
+ if (!engine) {
84
+ const name = String(c.model || c.id || '');
85
+ if (!name) return null;
86
+ if (c.kind === 'bridge' || c.kind === 'harness' || c.classUsed === 'A') {
87
+ const slash = name.indexOf('/');
88
+ engine = slash > 0 ? { kind: 'harness', id: name.slice(0, slash), model: name.slice(slash + 1) } : { kind: 'harness', id: name };
89
+ } else engine = { kind: 'model', id: name };
90
+ }
91
+ const key = engineKey(engine);
92
+ if (!key) return null;
93
+ const { override, observed } = cardOverride(card, { minCalls, jobKind });
94
+ const withdrawn = new Set(card?.capabilities?.withdrawn || []);
95
+ const capabilities = [...new Set(lower(c.capabilities))].filter((x) => !withdrawn.has(x));
96
+ if (withdrawn.size && lower(c.capabilities).some((x) => withdrawn.has(x))) observed.push('capabilities');
97
+ const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
98
+ const reach = REACH_RANK[c.reach] != null ? c.reach : 'any';
99
+ // A model on this machine costs nothing per token — the router's own rule (`costOf`), kept
100
+ // here for a host that has no guess to offer; an unknown cost anywhere else stays unknown
101
+ // and orders as the dearest, so "we did not price it" never reads as "free".
102
+ const costPer1k = override.costPer1k ?? num(c.costPer1k) ?? (reach === 'device' && engine.kind === 'model' ? 0 : null);
103
+ return {
104
+ key,
105
+ engine,
106
+ label: clip(c.label || c.name || engineName(engine), 120),
107
+ reach,
108
+ capabilities,
109
+ quality: override.quality ?? num(c.quality),
110
+ latencyMs: override.latencyMs ?? num(c.latencyMs),
111
+ costPer1k,
112
+ costPerTask: num(card?.cost?.perTask),
113
+ availability: num(card?.availability?.rate),
114
+ available: override.available ?? (c.available !== false && c.usable !== false),
115
+ observed,
116
+ };
117
+ }
118
+ const engineName = (e) => `${e.id}${e.model && e.model !== e.id ? `/${e.model}` : ''}`;
119
+
120
+ /** Rows from a host's candidates and the cards it holds (by key). */
121
+ export function engineRows(candidates, { cards = {}, minCalls, jobKind } = {}) {
122
+ const out = []; const seen = new Set();
123
+ for (const c of Array.isArray(candidates) ? candidates : []) {
124
+ const row = engineRow(c, { card: null, minCalls, jobKind });
125
+ if (!row || seen.has(row.key)) continue;
126
+ seen.add(row.key);
127
+ out.push(cards[row.key] ? engineRow(c, { card: cards[row.key], minCalls, jobKind }) : row);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // ── What the job needs of an engine ───────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Requirements eliminate; they are never traded for cost or speed. A work grant (`shell`,
136
+ * `fs:write`, `scm:*`) can only be exercised by a harness — a chat model has no shell. Tools
137
+ * or any grant beyond `none` mean the turn may call tools. Reach is the project's privacy
138
+ * ceiling, typed, never learned.
139
+ */
140
+ export function needForJob(job, { reach = 'any' } = {}) {
141
+ const grants = lower(job?.needs?.grants).filter((g) => g !== 'none');
142
+ const tools = lower(job?.needs?.tools);
143
+ const harness = grants.some((g) => WORK_GRANTS.includes(g));
144
+ const capabilities = [];
145
+ const why = [];
146
+ if (tools.length || grants.length) { capabilities.push('tools'); why.push('the job uses tools'); }
147
+ if (harness) why.push(`a work grant (${grants.filter((g) => WORK_GRANTS.includes(g)).join(', ')}) needs a harness`);
148
+ const r = REACH_RANK[reach] != null ? reach : 'any';
149
+ if (r !== 'any') why.push(`reach ≤ ${r} (the project's privacy setting)`);
150
+ return { capabilities, harness, reach: r, why };
151
+ }
152
+
153
+ // ── Routing: the engine for this agent on this job ────────────────────────────────────────
154
+
155
+ const meets = (row, need, policy) => {
156
+ const why = [];
157
+ if (row.available === false) why.push('unavailable right now');
158
+ if (REACH_RANK[row.reach] > REACH_RANK[need.reach]) why.push(`reach ${row.reach} exceeds ${need.reach}`);
159
+ if (need.harness && row.engine.kind !== 'harness') why.push('not a harness');
160
+ const missing = need.capabilities.filter((c) => !row.capabilities.includes(c));
161
+ // A harness brings its own tools; the capability list of a bridge agent is the host's guess.
162
+ if (missing.length && !(row.engine.kind === 'harness' && missing.every((c) => c === 'tools'))) why.push(`lacks ${missing.join(', ')}`);
163
+ if (policy) {
164
+ const matches = (refs) => (refs || []).some((k) => k === row.key || k === `${row.engine.kind}:${row.engine.id}` || k === row.engine.id || k === row.engine.model);
165
+ if (policy.allow?.length && !matches(policy.allow)) why.push('not on the policy\'s allow list');
166
+ if (policy.deny?.length && matches(policy.deny)) why.push('on the policy\'s deny list');
167
+ if (policy.floor?.quality != null && row.quality != null && row.quality < policy.floor.quality) why.push(`quality ${row.quality} under the floor ${policy.floor.quality}`);
168
+ if (policy.floor?.availability != null && row.availability != null && row.availability < policy.floor.availability) why.push(`availability ${row.availability} under the floor ${policy.floor.availability}`);
169
+ if (policy.ceiling?.costPerTask != null && row.costPerTask != null && row.costPerTask > policy.ceiling.costPerTask) why.push(`$${row.costPerTask}/task over the ceiling`);
170
+ if (policy.ceiling?.latencyMs != null && row.latencyMs != null && row.latencyMs > policy.ceiling.latencyMs) why.push(`${row.latencyMs} ms over the ceiling`);
171
+ }
172
+ return why;
173
+ };
174
+
175
+ const ownRating = (summary, key) => (summary?.byEngine || []).find((r) => r.key === key)?.rating?.avg ?? null;
176
+
177
+ /** Order the rows that clear by the policy's preference; the agent's own record on an engine breaks ties. */
178
+ function orderByPolicy(rows, prefer, summary) {
179
+ const q = (r) => r.quality ?? 0.5;
180
+ const cost = (r) => r.costPerTask ?? r.costPer1k ?? null;
181
+ const maxCost = Math.max(...rows.map((r) => cost(r) ?? 0), 0) || 1;
182
+ const maxLat = Math.max(...rows.map((r) => r.latencyMs ?? 0), 0) || 1;
183
+ const own = (r) => { const v = ownRating(summary, r.key); return v == null ? 0 : v - 0.5; };
184
+ const score = (r) => {
185
+ switch (prefer) {
186
+ case 'cheapest-that-clears': return -((cost(r) ?? maxCost) / maxCost) + q(r) * 0.01;
187
+ case 'best-quality': return q(r) - ((cost(r) ?? maxCost) / maxCost) * 0.01;
188
+ case 'fastest': return -((r.latencyMs ?? maxLat) / maxLat) + q(r) * 0.01;
189
+ default: return q(r) * 0.5 + (1 - (cost(r) ?? maxCost) / maxCost) * 0.25 + (1 - (r.latencyMs ?? maxLat) / maxLat) * 0.25;
190
+ }
191
+ };
192
+ return rows.map((r) => ({ row: r, score: score(r) + own(r) * 0.05 })).sort((a, b) => b.score - a.score).map((x) => x.row);
193
+ }
194
+
195
+ const rowLine = (r) => `${r.key} (quality ${r.quality ?? '?'}${r.observed.includes('quality') ? ' observed' : ''}${r.costPerTask != null ? `, $${r.costPerTask}/task` : r.costPer1k != null ? `, $${r.costPer1k}/1k` : ''}${r.latencyMs != null ? `, ${r.latencyMs} ms` : ''})`;
196
+
197
+ /**
198
+ * The engine this agent would run this job on, and why — `{ engine, key, reasons,
199
+ * alternatives, exploration, clears }`; `clears: false` (engine null) when nothing does, with
200
+ * the reasons. `rows` are the host's `engineRows`; an empty roster trusts a fixed spec and
201
+ * refuses `auto` (nothing to pick from). `explore` takes one tier cheaper than the policy's
202
+ * pick when a cheaper row clears — the project loop's bounded exploration (§13.4); never for
203
+ * a harness.
204
+ */
205
+ export function routeFor(agent, job, { rows = [], summary = null, need = null, reach = 'any', chatModel = null, explore = false } = {}) {
206
+ const n = need || needForJob(job, { reach });
207
+ const spec = engineOf(agent, { chatModel });
208
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
209
+ const none = (reasons) => ({ engine: null, key: null, reasons, alternatives: [], exploration: false, clears: false });
210
+ if (spec.kind !== 'auto') {
211
+ const key = engineKeyOf(spec);
212
+ const exact = list.find((r) => r.key === key);
213
+ // A harness card without a model matches any row of that harness; a model spec without a
214
+ // provider matches the row that runs that model anywhere.
215
+ const near = exact || list.find((r) => r.engine.kind === spec.kind && (spec.kind === 'harness' ? r.engine.id === spec.harnessId && !spec.model : (r.engine.id === spec.model || r.engine.model === spec.model) && !spec.providerId));
216
+ if (!list.length) return { engine: normalizeEngine({ kind: spec.kind, id: spec.kind === 'harness' ? spec.harnessId : (spec.providerId || spec.model), model: spec.model }), key, reasons: [`${describeEngine(spec)} — pinned by the agent; no roster to check it against`], alternatives: [], exploration: false, clears: true };
217
+ if (!near) return none([`${describeEngine(spec)} is pinned by the agent but is not installed or configured here`]);
218
+ const why = meets(near, n, null);
219
+ if (why.length) return none([`${describeEngine(spec)} is pinned by the agent but ${why.join('; ')}`]);
220
+ return { engine: near.engine, key: near.key, reasons: ['pinned by the agent', ...n.why], alternatives: [], exploration: false, clears: true };
221
+ }
222
+ if (!list.length) return none(['engine is auto and the roster is empty']);
223
+ const policy = spec.policy || {};
224
+ const rejected = [];
225
+ const cleared = list.filter((r) => { const why = meets(r, n, policy); if (why.length) rejected.push(`${r.key}: ${why.join('; ')}`); return !why.length; });
226
+ if (!cleared.length) return none([`no engine clears ${n.harness ? 'a harness with ' : ''}${n.capabilities.join(', ') || 'the requirements'}${n.reach !== 'any' ? ` within reach ${n.reach}` : ''}${policy.prefer ? ` under ${policy.prefer}` : ''}`, ...rejected.slice(0, 4)]);
227
+ const ordered = orderByPolicy(cleared, policy.prefer || 'balanced', summary);
228
+ let pick = ordered[0];
229
+ let exploration = false;
230
+ const cost = (r) => r.costPerTask ?? r.costPer1k ?? null;
231
+ if (explore && pick.engine.kind !== 'harness') {
232
+ const cheaper = ordered.filter((r) => r.engine.kind !== 'harness' && cost(r) != null && cost(pick) != null && cost(r) < cost(pick)).sort((a, b) => cost(b) - cost(a));
233
+ if (cheaper.length) { exploration = true; pick = cheaper[0]; }
234
+ }
235
+ const reasons = [
236
+ exploration ? `exploration: one tier cheaper than the policy's pick (${ordered[0].key})` : `${(policy.prefer || 'balanced').replace(/-/g, ' ')}: ${rowLine(pick)}`,
237
+ ...n.why,
238
+ ];
239
+ const own = ownRating(summary, pick.key);
240
+ if (own != null) reasons.push(`this agent rated ${Math.round(own * 100)}% on it before`);
241
+ if (policy.floor?.quality != null || policy.ceiling?.costPerTask != null || policy.ceiling?.latencyMs != null) reasons.push(`${cleared.length} of ${list.length} engines clear the policy`);
242
+ return { engine: pick.engine, key: pick.key, reasons, alternatives: ordered.filter((r) => r !== pick).slice(0, 4).map((r) => r.engine), exploration, clears: true };
243
+ }
244
+
245
+ // ── Applying: the whole pool at once ──────────────────────────────────────────────────────
246
+
247
+ /** `qualityOf` / `costOf` for `adjustSummary`, read from the rows: what each engine is worth. */
248
+ export function engineWorth(rows) {
249
+ const byKey = new Map((rows || []).filter(Boolean).map((r) => [r.key, r]));
250
+ return {
251
+ qualityOf: (key) => byKey.get(key)?.quality ?? null,
252
+ costOf: (key) => { const r = byKey.get(key); return r ? (r.costPerTask ?? null) : null; },
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Every eligible agent applies: `fit` (needs → adjusted record → size) plus the engine it
258
+ * would run on. `summaries` are scorecard cards by agent id (`summarize()`); `rows` are
259
+ * `engineRows`. Best first, recruitable (an engine clears) before not. Each application is
260
+ * `{ agentId, engine?, fit, reasons, pitch, at, covers }` — job.js's shape, the record's, plus
261
+ * `covers` (has a skill the job names) for `decide`; the record drops it.
262
+ */
263
+ export function applications(job, pool, { summaries = {}, rows = [], reach = 'any', chatModel = null, adjust = true, now = Date.now() } = {}) {
264
+ const need = needForJob(job, { reach });
265
+ const worth = engineWorth(rows);
266
+ const fitFn = (j, agent, summary) => {
267
+ const f = fit(j, { ...agent, tools: agent.tools || agent.grants }, summary, adjust ? worth : { adjust: false });
268
+ const route = routeFor(agent, j, { rows, summary, need, chatModel });
269
+ return { score: f.score, reasons: [...f.reasons, ...route.reasons].slice(0, 8), ...(route.clears ? { engine: route.engine } : {}), covers: coversSkills(j, agent) };
270
+ };
271
+ // `covers` — has at least one skill the job names (or the job names none) — rides on the
272
+ // live application for `decide`; the record keeps job.js's shape and drops it.
273
+ const covered = new Map((pool || []).map((a) => [a?.id, coversSkills(job, a)]));
274
+ return applyAll(job, pool, fitFn, { cards: summaries, now }).map((a) => ({ ...a, covers: covered.get(a.agentId) !== false }));
275
+ }
276
+
277
+ /** A job that names skills is not given to an agent with none of them on fit alone: tools and grants it never asked for count for nothing. */
278
+ function coversSkills(job, agent) {
279
+ const want = lower(job?.needs?.skills);
280
+ if (!want.length) return true;
281
+ const has = new Set(lower(agent?.skills));
282
+ return want.some((s) => has.has(s));
283
+ }
284
+
285
+ // ── Evaluating: one structured call, or none ──────────────────────────────────────────────
286
+
287
+ /** The evaluator's instruction: the job, the top applicants with their fit, engine and reasons, the shape to answer in. */
288
+ export function evaluatorPrompt(job, apps, pool = [], { rows = [], top = TOP_APPLICANTS } = {}) {
289
+ const byId = new Map((pool || []).map((a) => [a.id, a]));
290
+ const rowOf = new Map((rows || []).filter(Boolean).map((r) => [r.key, r]));
291
+ const needs = job?.needs || {};
292
+ const lines = (apps || []).slice(0, top).map((a) => {
293
+ const agent = byId.get(a.agentId) || {};
294
+ const key = a.engine ? engineKey(a.engine) : null;
295
+ const row = key ? rowOf.get(key) : null;
296
+ return `- ${a.agentId} (fit ${Math.round((a.fit || 0) * 100)}%)${agent.purpose ? ` — ${clip(agent.purpose, 160)}` : ''}; skills: ${(agent.skills || []).join(', ') || 'none'}; grants: ${(agent.grants || []).join(', ') || 'none'}; ${key ? `engine: ${row ? rowLine(row) : key}` : 'NOT RECRUITABLE NOW — no engine clears'}; ${(a.reasons || []).slice(0, 4).join('; ')}`;
297
+ });
298
+ return [
299
+ `You are the evaluator for the job "${clip(job?.title, 200)}" on project ${job?.projectId || '?'}. Recruit ONE applicant, or say none fits.`,
300
+ `Brief: ${clip(job?.brief, MAX_BRIEF_IN_PROMPT)}`,
301
+ `Needs — skills: ${(needs.skills || []).join(', ') || 'none named'}; tools: ${(needs.tools || []).join(', ') || 'none named'}; grants: ${(needs.grants || []).join(', ') || 'none'}${job?.budget ? `; budget: ${Object.entries(job.budget).map(([k, v]) => `${v} ${k}`).join(', ')}` : ''}.`,
302
+ '',
303
+ 'Applicants, best computed fit first (fit = the skills, tools and grants the job names, then the attested record, then size):',
304
+ ...(lines.length ? lines : ['- (no one applied)']),
305
+ '',
306
+ 'Rules: prefer the applicant that has what the job names and a record of clearing work like it on the engine shown; never pick one marked NOT RECRUITABLE NOW; a lower fit is right only when its reasons show the higher one lacks something the brief needs. When no applicant has the skills the job names, answer pick "" and propose the agent that should exist.',
307
+ '',
308
+ describeSchema(RECRUIT_SCHEMA),
309
+ ].join('\n');
310
+ }
311
+
312
+ /**
313
+ * The evaluator's answer, read through the schema and checked against the applications: a
314
+ * pick must be a recruitable applicant, else it is "none". Returns `{ pick, why, confidence,
315
+ * proposal }` or null when the text is unreadable.
316
+ */
317
+ export function parseEvaluation(text, apps = []) {
318
+ const got = coerce(text, RECRUIT_SCHEMA);
319
+ if (!got) return null;
320
+ const v = got.value;
321
+ const pick = String(v.pick || '').trim();
322
+ const app = pick ? (apps || []).find((a) => a.agentId === pick) : null;
323
+ const proposal = v.proposalName ? { name: clip(v.proposalName, 60), purpose: clip(v.proposalPurpose, 300), skills: (v.proposalSkills || []).map((s) => clip(s, 80)).filter(Boolean), grants: (v.proposalGrants || []).map((g) => clip(g, 64)).filter(Boolean) } : null;
324
+ if (app && app.engine) return { pick, why: clip(v.why, 400) || 'the evaluator\'s pick', confidence: r3(v.confidence), proposal: null };
325
+ return { pick: '', why: app ? `the evaluator picked ${pick}, which no engine can run right now` : clip(v.why, 400) || 'none fits', confidence: r3(v.confidence), proposal };
326
+ }
327
+
328
+ /**
329
+ * The decision: the evaluator's when one was made; else the best recruitable fit at or above
330
+ * `minFit`; else none, with the agent the job's needs describe as the proposal. Returns
331
+ * `{ kind: 'recruit', agentId, engine, fit, why, by }` or `{ kind: 'none', why, proposal, by }`.
332
+ */
333
+ export function decide(job, apps, { evaluation = null, minFit = MIN_FIT } = {}) {
334
+ const list = apps || [];
335
+ if (evaluation && evaluation.pick) {
336
+ const app = list.find((a) => a.agentId === evaluation.pick && a.engine);
337
+ if (app) return { kind: 'recruit', agentId: app.agentId, engine: app.engine, fit: app.fit, why: evaluation.why, by: 'evaluator' };
338
+ }
339
+ if (evaluation && !evaluation.pick) return { kind: 'none', why: evaluation.why, proposal: evaluation.proposal || proposalFromNeeds(job), by: 'evaluator' };
340
+ const best = list.find((a) => a.engine && a.covers !== false);
341
+ if (best && best.fit >= minFit) return { kind: 'recruit', agentId: best.agentId, engine: best.engine, fit: best.fit, why: `best fit (${Math.round(best.fit * 100)}%): ${(best.reasons || [])[0] || 'meets the needs'}`, by: 'fit' };
342
+ const why = !list.length ? 'no one in the pool applies to jobs'
343
+ : !best ? (list.some((a) => a.engine) ? `no applicant has a skill the job names (${(job?.needs?.skills || []).join(', ')})` : `${list.length} applied but no engine clears for any of them: ${(list[0].reasons || []).find((r) => /engine|pinned|roster/.test(r)) || 'see the applications'}`)
344
+ : `the best fit is ${Math.round(best.fit * 100)}%, under the ${Math.round(minFit * 100)}% floor: ${(best.reasons || []).find((r) => /missing/.test(r)) || best.reasons?.[0] || ''}`;
345
+ return { kind: 'none', why, proposal: proposalFromNeeds(job), by: 'fit' };
346
+ }
347
+
348
+ /** The agent a job's needs describe — what to propose when no one fits. */
349
+ export function proposalFromNeeds(job) {
350
+ const needs = job?.needs || {};
351
+ return { name: clip(job?.title, 60) || 'New agent', purpose: `Does jobs like "${clip(job?.title, 80)}".`, skills: [...(needs.skills || [])], grants: (needs.grants || []).length ? [...needs.grants] : ['none'] };
352
+ }
353
+
354
+ /**
355
+ * A proposal as an agent card for a person to approve — validated by the pool's own form
356
+ * (agentFromForm), engine `auto`, `createdBy: 'evaluator'`, its origin the job. Nothing
357
+ * joins the pool without a decision (D-A2): this returns the card, it does not store it.
358
+ */
359
+ export function proposalToAgent(proposal, job, { by = 'evaluator' } = {}) {
360
+ const p = proposal || proposalFromNeeds(job);
361
+ const known = new Set(['data', 'web', 'history', 'mcp', ...WORK_GRANTS]);
362
+ const grants = (p.grants || []).map((g) => String(g).toLowerCase()).filter((g) => known.has(g) || /^mcp:/.test(g));
363
+ return agentFromForm({
364
+ name: p.name, purpose: p.purpose, skills: p.skills,
365
+ prompt: `You are ${p.name}. ${p.purpose || ''}\n\nYou were proposed for the job "${clip(job?.title, 200)}" because no agent in the pool fit it. Do work like it well; ask on the thread when the brief is unclear.`,
366
+ grants: grants.length ? grants : ['none'], engine: { kind: 'auto', prefer: 'balanced' }, appliesTo: ['jobs'], createdBy: by,
367
+ origin: { kind: 'proposal', projectId: job?.projectId, jobId: job?.id },
368
+ });
369
+ }
370
+
371
+ // ── Landing it on the record ──────────────────────────────────────────────────────────────
372
+
373
+ /**
374
+ * The budget a recruit gets: the job's own when it has one, else an equal share of what the
375
+ * project has left (its budget minus its spend) across the jobs still to be recruited — so
376
+ * one unbudgeted job cannot take the whole project. `record` is the project record
377
+ * (project.js `foldProject`).
378
+ */
379
+ export function carveBudget(job, record = null) {
380
+ if (job?.budget && Object.keys(job.budget).length) return normalizeBudget(job.budget);
381
+ const cap = record?.page?.budget || {};
382
+ const spent = record?.spend || {};
383
+ const waiting = Math.max(1, (record?.jobs || []).filter((j) => ['open', 'evaluating'].includes(j.status)).length);
384
+ const out = {};
385
+ for (const k of ['tokens', 'calls', 'ms', 'usd']) {
386
+ if (!(Number(cap[k]) > 0)) continue;
387
+ const left = Math.max(0, Number(cap[k]) - (Number(spent[k]) || 0));
388
+ if (left > 0) out[k] = k === 'usd' ? Math.round((left / waiting) * 100) / 100 : Math.max(1, Math.floor(left / waiting));
389
+ }
390
+ return Object.keys(out).length ? out : null;
391
+ }
392
+
393
+ /**
394
+ * The events that record a recruiting pass on the project (project.js fold): the job moves
395
+ * to `evaluating` with its applications, then to `recruited` with the pair, the budget and
396
+ * the why — or back to `open`, with the proposal as a decision a person reads.
397
+ */
398
+ export function recruitEvents(job, apps, decision, { by = 'evaluator', at = Date.now(), record = null } = {}) {
399
+ const events = [{ type: 'job.updated', at, job: { id: job.id, status: 'evaluating', applications: (apps || []).map(({ covers: _c, ...a }) => a) }, by }];
400
+ if (decision?.kind === 'recruit') {
401
+ const budget = carveBudget(job, record);
402
+ events.push({ type: 'job.updated', at, job: { id: job.id, status: 'recruited', recruited: { agentId: decision.agentId, engine: decision.engine, ...(budget ? { budget } : {}), by: decision.by || by, at, why: clip(decision.why, 600) } }, by });
403
+ } else {
404
+ events.push({ type: 'job.updated', at, job: { id: job.id, status: 'open' }, by });
405
+ const p = decision?.proposal;
406
+ events.push({ type: 'project.decision', at, by, kind: 'proposal', text: `No one in the pool fits "${clip(job.title, 120)}": ${clip(decision?.why, 400)}${p ? ` Proposed: ${p.name}${p.skills?.length ? ` — skills ${p.skills.join(', ')}` : ''}${p.grants?.length ? `; grants ${p.grants.join(', ')}` : ''}.` : ''}`, refs: [`job:${job.id}`] });
407
+ }
408
+ return events;
409
+ }
410
+
411
+ /**
412
+ * One pass, end to end: apply → (evaluate) → decide → the events. `ask(prompt) → text |
413
+ * null` is the host's structured call; absent or failing, the fit decides. Returns
414
+ * `{ applications, evaluation, decision, events, prompt }`.
415
+ */
416
+ export async function recruitJob(job, pool, { summaries = {}, rows = [], reach = 'any', chatModel = null, record = null, ask = null, minFit = MIN_FIT, by = 'evaluator', now = Date.now() } = {}) {
417
+ const apps = applications(job, pool, { summaries, rows, reach, chatModel, now });
418
+ const prompt = evaluatorPrompt(job, apps, pool, { rows });
419
+ let evaluation = null;
420
+ if (ask && apps.some((a) => a.engine)) {
421
+ try { const text = await ask(prompt, RECRUIT_SCHEMA); evaluation = text == null ? null : (typeof text === 'string' ? parseEvaluation(text, apps) : parseEvaluation(JSON.stringify(text), apps)); } catch { evaluation = null; }
422
+ }
423
+ const decision = decide(job, apps, { evaluation, minFit });
424
+ return { applications: apps, evaluation, decision, events: recruitEvents(job, apps, decision, { by: decision.by === 'evaluator' ? by : 'fit', at: now, record }), prompt };
425
+ }
@@ -0,0 +1,93 @@
1
+ // Recruiting on the gateway — the pool, the cards and the roster THIS machine knows, handed
2
+ // to the shared recruiter (recruit.js, vendored) for one job.
3
+ //
4
+ // The pool is the shared `agents` prefs section; each agent's record is its attested
5
+ // scorecard chain (scorecard-store.js); each engine's card is its attested ledger
6
+ // (engine-ledger-store.js); the roster is the gateway's own model list — every destination it
7
+ // routes to, with the bridge's word on which agents are installed. None of that is the
8
+ // client's to claim: a client asks for the applications and posts the evaluator's answer, and
9
+ // the gateway recomputes the fit before it records a pick. The gateway makes no model call of
10
+ // its own here — the evaluator is the client's structured call (`runStructured`), and when no
11
+ // client makes one the fit decides, so a job is recruitable from a curl.
12
+
13
+ import { summarize } from './scorecard.js';
14
+ import { applications, evaluatorPrompt, parseEvaluation, decide, recruitEvents, engineRows, MIN_FIT } from './recruit.js';
15
+ import { aggregateModelsAsync, listDestinations } from './router.js';
16
+
17
+ const REACH = new Set(['device', 'trusted', 'any']);
18
+
19
+ /** Where a destination's model runs, typed from its address — never learned. */
20
+ function reachOfDestination(d) {
21
+ if (!d || d.type === 'agent') return 'trusted';
22
+ const url = String(d.baseUrl || '');
23
+ if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(:|\/|$)/i.test(url)) return 'device';
24
+ if (/^https?:\/\/(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|[^/]+\.local(:|\/|$))/i.test(url)) return 'trusted';
25
+ return 'any';
26
+ }
27
+
28
+ /**
29
+ * The roster as engine rows: the gateway's model list (`aggregateModelsAsync`) with each
30
+ * engine's card applied. A bridge agent is a harness; an API model's reach is its
31
+ * destination's; `available` is the bridge's word for agents (absent = unknown, taken as
32
+ * usable) and `configured` for API destinations. Quality is the card's when observed —
33
+ * the gateway does not vendor the router's name-based guess, so an unrated engine is the
34
+ * 0.5 prior and the recruiter's reasons say so.
35
+ */
36
+ export async function rosterRows(cfg, engines, { jobKind = null, minCalls, timeoutMs = 2500 } = {}) {
37
+ const models = await aggregateModelsAsync(cfg, { timeoutMs }).catch(() => ({ data: [] }));
38
+ const dests = new Map(listDestinations(cfg).map((d) => [d.id, d]));
39
+ const candidates = (models.data || []).map((m) => {
40
+ const agent = m.owned_by === 'chatpanel-bridge';
41
+ const d = dests.get(m.provider);
42
+ return {
43
+ id: m.id, model: m.id, kind: agent ? 'bridge' : 'api', label: m.model ? `${m.provider} · ${m.model}` : m.id,
44
+ reach: agent ? 'trusted' : reachOfDestination(d),
45
+ capabilities: ['tools'],
46
+ available: m.configured === false ? false : (m.available !== false),
47
+ };
48
+ });
49
+ const cards = Object.fromEntries((engines?.list?.({ minCalls }) || []).map((c) => [c.key, c]));
50
+ return engineRows(candidates, { cards, minCalls, jobKind });
51
+ }
52
+
53
+ /** The pool: the shared `agents` section, enabled cards only. */
54
+ export function poolFrom(prefsStore) {
55
+ const v = prefsStore?.get?.('agents')?.agents?.value;
56
+ return (Array.isArray(v) ? v : []).filter((a) => a && a.id && a.enabled !== false);
57
+ }
58
+
59
+ /** Every applicant's card, from the attested chains. */
60
+ export function summariesFrom(scorecards, pool) {
61
+ const out = {};
62
+ for (const a of pool) { const chain = scorecards?.chains?.get?.(a.id); if (chain?.length) out[a.id] = summarize(chain); }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * The applications for a job as this gateway sees them, plus the evaluator's prompt a client
68
+ * runs through its structured layer. `reach` is the project's privacy ceiling, the client's
69
+ * to state (default `any`).
70
+ */
71
+ export async function applicationsFor(job, { cfg, prefsStore, scorecards, engines, reach = 'any', chatModel = null, now = Date.now() } = {}) {
72
+ const pool = poolFrom(prefsStore);
73
+ const rows = await rosterRows(cfg, engines);
74
+ const summaries = summariesFrom(scorecards, pool);
75
+ const r = REACH.has(reach) ? reach : 'any';
76
+ const apps = applications(job, pool, { summaries, rows, reach: r, chatModel, now });
77
+ return { applications: apps, prompt: evaluatorPrompt(job, apps, pool, { rows }), rows: rows.map((x) => ({ key: x.key, label: x.label, reach: x.reach, quality: x.quality, costPer1k: x.costPer1k, latencyMs: x.latencyMs, available: x.available, observed: x.observed })), poolSize: pool.length };
78
+ }
79
+
80
+ /**
81
+ * One recruiting pass: recompute the applications (never trust a posted fit), read the
82
+ * client's evaluation when it sent one (`evaluation` parsed, or `text` raw — the schema
83
+ * reads it), decide, and return the events for the project record. The caller appends them.
84
+ */
85
+ export async function recruitPass(job, { cfg, prefsStore, scorecards, engines, record = null, reach = 'any', chatModel = null, evaluation = null, text = null, by = 'evaluator', minFit = MIN_FIT, now = Date.now() } = {}) {
86
+ const { applications: apps, prompt } = await applicationsFor(job, { cfg, prefsStore, scorecards, engines, reach, chatModel, now });
87
+ let ev = null;
88
+ if (text != null) ev = parseEvaluation(String(text), apps);
89
+ else if (evaluation && typeof evaluation === 'object') ev = parseEvaluation(JSON.stringify({ pick: evaluation.pick, why: evaluation.why, confidence: evaluation.confidence, proposalName: evaluation.proposal?.name, proposalPurpose: evaluation.proposal?.purpose, proposalSkills: evaluation.proposal?.skills, proposalGrants: evaluation.proposal?.grants }), apps);
90
+ const decision = decide(job, apps, { evaluation: ev, minFit });
91
+ const events = recruitEvents(job, apps, decision, { by: decision.by === 'evaluator' ? by : 'fit', at: now, record });
92
+ return { applications: apps, evaluation: ev, decision, events, prompt };
93
+ }
@@ -82,7 +82,7 @@ export class ScorecardStore {
82
82
  if (!p.agentId) return null;
83
83
  return this.append({
84
84
  agentId: p.agentId, kind: p.outcome === 'task.failed' ? 'task.failed' : 'task.done', at: ev.at,
85
- runId: run?.id || p.runId, taskId: p.taskId, model: p.model, size: p.size, roleKind: p.roleKind,
85
+ runId: run?.id || p.runId, taskId: p.taskId, model: p.model, engine: p.engine, scm: p.scm, size: p.size, roleKind: p.roleKind,
86
86
  tools: p.tools, with: p.with, refs: p.refs, error: p.error,
87
87
  ...(run?.projectId ? { projectId: run.projectId } : {}), ...(run?.jobId ? { jobId: run.jobId } : {}),
88
88
  }).catch(() => null);