@chatpanel/gateway 0.6.90 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.90",
3
+ "version": "0.6.91",
4
4
  "description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js ADDED
@@ -0,0 +1,250 @@
1
+ // VENDORED from @chatpanel/events/agent.js — edit there, then copy over.
2
+ // An AGENT, as data — the pool an org recruits from. Nothing runs here.
3
+ //
4
+ // A team's roles were inlined: each carried its own prompt, tier and grants, so the same
5
+ // "researcher" existed once per team and an edit landed in one of them. An agent is the role
6
+ // definition promoted out of the team (F8 §8 A1, architecture-pillars.md §9): a persistent
7
+ // identity — name, purpose, prompt, skills, grants, egress class, ENGINE, memory namespace —
8
+ // with a scorecard the runner writes and the store attests (scorecard.js). One agent stands
9
+ // in many teams; a team's role says `agent: <id>` and `resolveTeam` fills the role from the
10
+ // pool at run time, so the runner (team-run.js) is unchanged.
11
+ //
12
+ // The engine is a field on the card, never a nav item: `model` / `harness` / `auto` /
13
+ // `assistant` (engine.js). The built-in ASSISTANT is the agent behind every plain chat: its
14
+ // engine is whichever model the chat is on, which is why `engineOf` takes the chat's model.
15
+ //
16
+ // Trust is derived, never declared (the team.js rule): `builtin` only when the host says so.
17
+ // Grants are the team's vocabulary (team.js GRANT_RE), including the work grants a harness
18
+ // engine may hold; `page` is never grantable. The pool is shared through the client-prefs
19
+ // document (`agents` section) like teams, skills and recipes.
20
+
21
+ import { normalizeGrants, GRANT_RE, TeamError, normalizeTeam, slugTeamName } from './team.js';
22
+ import { normalizeEngineSpec, validateEngineSpec, describeEngine, engineRef, tierOf } from './engine.js';
23
+
24
+ export const AGENT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
25
+ export const APPLIES_TO = Object.freeze(['jobs', 'meetings', 'notes']);
26
+ export const EGRESS_CLASSES = Object.freeze(['redacted', 'delegated']);
27
+ export const MAX_SKILLS = 32;
28
+ export const ASSISTANT_ID = 'assistant';
29
+
30
+ export class AgentError extends Error {
31
+ constructor(code, message) { super(message); this.name = 'AgentError'; this.code = code; }
32
+ }
33
+
34
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
35
+ const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => String(x || '').trim().slice(0, n)).filter(Boolean))].slice(0, max);
36
+
37
+ export function validateAgent(agent) {
38
+ const errors = [];
39
+ if (!isRecord(agent)) return { ok: false, errors: ['agent must be an object'] };
40
+ if (!AGENT_ID_RE.test(String(agent.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
41
+ if (!String(agent.name || agent.id || '').trim()) errors.push('name: what to call it');
42
+ if (!String(agent.prompt || '').trim() && String(agent.id) !== ASSISTANT_ID) errors.push('prompt: what this agent does');
43
+ errors.push(...validateEngineSpec(agent.engine, 'engine'));
44
+ const bad = (Array.isArray(agent.grants) ? agent.grants : []).filter((g) => !GRANT_RE.test(String(g)));
45
+ if (bad.length) errors.push(`grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; an agent may not act on it)' : ''}`);
46
+ if (agent.egress !== undefined && agent.egress !== null && !EGRESS_CLASSES.includes(agent.egress)) errors.push(`egress: one of ${EGRESS_CLASSES.join(', ')}`);
47
+ if (agent.appliesTo !== undefined && (!Array.isArray(agent.appliesTo) || agent.appliesTo.some((a) => !APPLIES_TO.includes(a)))) errors.push(`appliesTo: a list of ${APPLIES_TO.join(', ')}`);
48
+ if (agent.skills !== undefined && !Array.isArray(agent.skills) && typeof agent.skills !== 'string') errors.push('skills: a list of skill names');
49
+ return { ok: errors.length === 0, errors };
50
+ }
51
+
52
+ /**
53
+ * The stored form. Defaults filled, grants normalized, engine normalized, trust derived. The
54
+ * memory namespace defaults to the agent's own (`agent:<id>`); a team may point several
55
+ * agents at one shared namespace by naming it.
56
+ */
57
+ export function normalizeAgent(agent, { builtin = false } = {}) {
58
+ const v = validateAgent(agent);
59
+ if (!v.ok) throw new AgentError('INVALID', v.errors.join('; '));
60
+ const id = String(agent.id);
61
+ return {
62
+ id,
63
+ name: String(agent.name || id).trim().slice(0, 60),
64
+ purpose: String(agent.purpose || '').trim().slice(0, 300),
65
+ prompt: String(agent.prompt || '').trim().slice(0, 8000),
66
+ skills: list(agent.skills, 80, MAX_SKILLS),
67
+ grants: normalizeGrants(agent.grants),
68
+ engine: normalizeEngineSpec(agent.engine),
69
+ ...(EGRESS_CLASSES.includes(agent.egress) ? { egress: agent.egress } : {}),
70
+ appliesTo: Array.isArray(agent.appliesTo) && agent.appliesTo.length ? [...new Set(agent.appliesTo.filter((a) => APPLIES_TO.includes(a)))] : ['jobs'],
71
+ memoryScope: String(agent.memoryScope || '').trim().slice(0, 120) || `agent:${id}`,
72
+ ...(agent.workdir ? { workdir: String(agent.workdir).trim().slice(0, 400) } : {}),
73
+ createdBy: String(agent.createdBy || 'person').slice(0, 80),
74
+ enabled: agent.enabled !== false,
75
+ ...(agent.origin && isRecord(agent.origin) ? { origin: { ...agent.origin } } : {}),
76
+ ...(builtin ? { builtin: true } : {}),
77
+ ...(agent.createdAt ? { createdAt: agent.createdAt } : {}),
78
+ };
79
+ }
80
+
81
+ export function defineAgent(agent, opts) { return Object.freeze(normalizeAgent(agent, opts)); }
82
+
83
+ /**
84
+ * The built-in Assistant — the agent behind every plain chat. Its engine is the chat's model
85
+ * (`engineOf` resolves it); its grants are whatever the chat has; it applies everywhere.
86
+ */
87
+ export function assistantAgent({ grants = ['data', 'web', 'history', 'mcp'] } = {}) {
88
+ return defineAgent({
89
+ id: ASSISTANT_ID, name: 'Assistant', purpose: 'The chat itself: answers, uses the tools you connected, remembers what you tell it.',
90
+ prompt: '', engine: 'assistant', grants, appliesTo: ['jobs', 'meetings', 'notes'], createdBy: 'chatpanel',
91
+ }, { builtin: true });
92
+ }
93
+
94
+ /**
95
+ * WHAT RUNS this agent's turns, resolved: the Assistant's engine is the chat's model (the
96
+ * host passes `chatModel` as `{ providerId?, model }` or a string), which is why the
97
+ * Assistant has no engine of its own. Everything else returns its normalized spec. A legacy
98
+ * role (no `engine`) reads as `model` when it pins one, else `auto` at its tier.
99
+ */
100
+ export function engineOf(agentOrRole, { chatModel = null } = {}) {
101
+ const a = agentOrRole || {};
102
+ if (a.engine !== undefined && a.engine !== null) {
103
+ const s = normalizeEngineSpec(a.engine);
104
+ if (s.kind !== 'assistant') return s;
105
+ if (chatModel) return normalizeEngineSpec(typeof chatModel === 'string' ? chatModel : { kind: 'model', providerId: chatModel.providerId, model: chatModel.model || chatModel.id });
106
+ return { kind: 'auto', policy: { prefer: 'balanced' } };
107
+ }
108
+ if (a.model) return normalizeEngineSpec({ kind: 'model', model: a.model });
109
+ const prefer = { cheap: 'cheapest-that-clears', strong: 'best-quality', balanced: 'balanced' }[a.prefer] || 'balanced';
110
+ return { kind: 'auto', policy: { prefer } };
111
+ }
112
+
113
+ /** The capability strip's first column (§8): what kind of thing this agent is, in a word. */
114
+ export function describeAgent(agent, opts) {
115
+ const a = agent || {};
116
+ return `${a.name || a.id} — ${describeEngine(a.engine, opts)} · tools: ${(a.grants || ['none']).join(', ')}${a.skills?.length ? ` · skills: ${a.skills.join(', ')}` : ''}`;
117
+ }
118
+
119
+ export function slugAgentId(name) { return slugTeamName(name); }
120
+
121
+ /**
122
+ * A team as the runner needs it: every role that says `agent` is filled from the pool —
123
+ * prompt, grants, skills, engine, working directory — and the role's own fields narrow it.
124
+ *
125
+ * • prompt the agent's, then the role's ("In this team: …") when the role adds one
126
+ * • grants the agent's, narrowed to the role's when the role lists any (a team may not
127
+ * widen an agent; it may lend less)
128
+ * • engine the role's override when it has one, else the agent's, with the Assistant
129
+ * resolved to `chatModel`; `prefer` follows for today's appointers
130
+ * • model what `callModel` is handed — `targetFor(engine)` when the host maps engines
131
+ * to its target ids (the extension's endpoint ids, the desktop's gateway ids),
132
+ * else the harness id or the model name
133
+ *
134
+ * A role naming an agent not in the pool throws NO_AGENT: a team is not run with a hole in
135
+ * it. Returns the normalized team plus `agents` — the resolved cards, by role id.
136
+ */
137
+ export function resolveTeam(team, pool = [], { chatModel = null, targetFor = null } = {}) {
138
+ const t = normalizeTeam(team);
139
+ const byId = new Map((Array.isArray(pool) ? pool : []).filter((a) => a && a.id).map((a) => [String(a.id), a]));
140
+ const agents = {};
141
+ const roles = t.roles.map((r) => {
142
+ if (!r.agent) return r;
143
+ const raw = byId.get(r.agent) || (r.agent === ASSISTANT_ID ? assistantAgent() : null);
144
+ if (!raw) throw new TeamError('NO_AGENT', `role "${r.id}" names agent "${r.agent}", which is not in the pool`);
145
+ const a = normalizeAgent(raw, { builtin: !!raw.builtin });
146
+ if (a.enabled === false) throw new TeamError('NO_AGENT', `agent "${a.id}" is disabled`);
147
+ agents[r.id] = a;
148
+ const engine = engineOf(r.engine ? { engine: r.engine } : a, { chatModel });
149
+ const ref = engineRef(engine);
150
+ const target = ref ? (targetFor ? targetFor(engine, a) : (engine.kind === 'harness' ? engine.harnessId : engine.model)) : null;
151
+ const grants = !Array.isArray(r.grants) ? a.grants
152
+ : a.grants.includes('none') ? ['none']
153
+ : normalizeGrants(r.grants.filter((g) => a.grants.includes(g) || (g.startsWith('mcp:') && a.grants.includes('mcp'))));
154
+ const prompt = [a.prompt, r.prompt ? `In this team: ${r.prompt}` : ''].filter(Boolean).join('\n\n').slice(0, 8000);
155
+ return {
156
+ ...r,
157
+ name: r.name === r.id ? a.name : r.name,
158
+ prompt,
159
+ grants,
160
+ engine,
161
+ prefer: engine.kind === 'auto' ? tierOf(engine) : r.prefer,
162
+ ...(target ? { model: r.model || target } : {}),
163
+ ...(a.skills.length ? { skills: [...a.skills] } : {}),
164
+ ...(a.workdir ? { workdir: a.workdir } : {}),
165
+ ...(a.egress ? { egress: a.egress } : {}),
166
+ ...(a.memoryScope ? { memoryScope: a.memoryScope } : {}),
167
+ };
168
+ });
169
+ return { ...t, roles, agents };
170
+ }
171
+
172
+ // ── The standing org and the editor's form ────────────────────────────────────────────────
173
+ //
174
+ // "ChatPanel Engineering" (architecture-pillars.md §12.2) — the first standing agents,
175
+ // offered as starters the way `research` and `review` are for teams. Every Implementer is
176
+ // this ONE card with a different working directory: the org repo's `agents/*.json` (§14.3)
177
+ // is where a company keeps one per repo. Engines are `auto` / `harness:<id>` so a starter
178
+ // does not name a provider a person may not have; the Harness engines say `claude` because
179
+ // that is the bridge's id for Claude Code and the pilot's choice — change it on the card.
180
+
181
+ export const STARTER_AGENTS = Object.freeze([
182
+ { id: 'architect', name: 'Architect', purpose: 'Reads the docs and the repos; writes the project page and the jobs.',
183
+ prompt: 'You are the Architect. Read the feature doc, ROADMAP.md, naming-revamp.md and architecture-pillars.md before deciding anything. Write the project page (goal, done-when, budget) and post one job per repo that must change, saying which repo and what the guard is. Propose a new agent type only when no one in the pool fits. Never run a shell.',
184
+ skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
185
+ { id: 'implementer', name: 'Implementer', purpose: 'Builds one job on a branch in one repo; runs the guard; posts on the thread. Never pushes to main, never publishes.',
186
+ prompt: 'You are an Implementer. Work only in the repository you were given, on the branch named for this job. Read the job thread first. Make the change, run the repository\'s guard (tools/test-*.mjs or npm test) until it passes, commit with a message that says what and why, and post a summary with the diff stat on the job thread. Do not push to main, do not publish, do not touch another repository.',
187
+ skills: [], grants: ['shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
188
+ { id: 'reviewer', name: 'Reviewer', purpose: 'Reads the branch diff; posts findings on the thread. Approve or reject is a person\'s.',
189
+ prompt: 'You are the Reviewer. Read the diff of the job\'s branch against its base. Post each finding as a reply on the job thread: what, where (file:line), why it matters, what to do instead. Do not edit files. Do not approve or reject — say what you found and let a person decide.',
190
+ skills: ['review'], grants: ['shell', 'scm:read'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
191
+ { id: 'tester', name: 'Tester', purpose: 'Runs the repository\'s guard on the branch; reports the result. No writes.',
192
+ prompt: 'You are the Tester. Check out the job\'s branch in its worktree and run the repository\'s guard (tools/test-*.mjs, npm test, the build). Report pass/fail with the failing output verbatim. Do not change any file.',
193
+ skills: [], grants: ['shell', 'scm:read'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
194
+ { id: 'librarian', name: 'Librarian', purpose: 'Before every job: what was already done for this, and where.',
195
+ prompt: 'You are the Librarian. Before a job starts, search the runs, boards, briefs and docs for work that already covers it. Reply on the job thread with "already done in …" pointers and what could be reused or extended. Never do the job yourself.',
196
+ skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'cheapest-that-clears' } }, appliesTo: ['jobs'] },
197
+ { id: 'scribe', name: 'Scribe', purpose: 'Writes what the run taught into the roadmap and the status row — proposed, a person lands it.',
198
+ prompt: 'You are the Scribe. From the run\'s board and scorecards, write the "what this run taught" paragraph for the feature doc, the ROADMAP.md entry and the IMPLEMENTATION-STATUS.md row. Post them as a proposal on the thread; a person lands them. Say plainly what did not work.',
199
+ skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'balanced' } }, appliesTo: ['jobs', 'notes'] },
200
+ { id: 'release', name: 'Release', purpose: 'Version bump and changelog on the merged branch; asks before publish or push.',
201
+ prompt: 'You are Release. On the merged branch: bump the version, write the changelog line, run the guard. Before `npm publish` or any `git push`, stop and ask on the thread — a person answers. Never publish or push without that answer.',
202
+ skills: [], grants: ['shell', 'fs:write', 'scm:read', 'scm:push'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
203
+ ]);
204
+
205
+ /** Fresh copies — a starter is a template, never the stored record. */
206
+ export function starterAgents() {
207
+ return STARTER_AGENTS.map((a) => JSON.parse(JSON.stringify(a)));
208
+ }
209
+
210
+ /** A blank agent for the editor. */
211
+ export function blankAgent() {
212
+ return { id: '', name: '', purpose: '', prompt: '', skills: [], grants: ['none'], engine: { kind: 'auto', policy: { prefer: 'balanced' } }, appliesTo: ['jobs'], memoryScope: '' };
213
+ }
214
+
215
+ /**
216
+ * The editor's form → an agent, or the errors. Grants and skills come as text ("web, data",
217
+ * "review, graphify"); the engine as the form's own shape (`{ kind, model, providerId,
218
+ * harnessId, prefer }`) or a string; a blank id is slugged from the name.
219
+ */
220
+ export function agentFromForm(form) {
221
+ const f = form || {};
222
+ const id = String(f.id || '').trim() || slugAgentId(f.name);
223
+ const engine = isRecord(f.engine)
224
+ ? (f.engine.kind === 'auto' ? { kind: 'auto', policy: { prefer: f.engine.prefer || f.engine.policy?.prefer || 'balanced', ...(f.engine.policy || {}) } } : f.engine)
225
+ : f.engine;
226
+ const agent = {
227
+ id,
228
+ name: String(f.name || '').trim() || id,
229
+ purpose: String(f.purpose || '').trim(),
230
+ prompt: String(f.prompt || ''),
231
+ skills: list(f.skills, 80, MAX_SKILLS),
232
+ grants: list(f.grants, 80, 32).length ? list(f.grants, 80, 32) : ['none'],
233
+ engine,
234
+ ...(f.egress ? { egress: f.egress } : {}),
235
+ appliesTo: Array.isArray(f.appliesTo) && f.appliesTo.length ? f.appliesTo : ['jobs'],
236
+ ...(f.memoryScope ? { memoryScope: f.memoryScope } : {}),
237
+ ...(f.workdir ? { workdir: f.workdir } : {}),
238
+ createdBy: f.createdBy || 'person',
239
+ enabled: f.enabled !== false,
240
+ ...(f.origin && isRecord(f.origin) ? { origin: f.origin } : {}),
241
+ ...(f.createdAt ? { createdAt: f.createdAt } : {}),
242
+ };
243
+ const v = validateAgent(agent);
244
+ return v.ok ? { ok: true, agent: normalizeAgent(agent) } : { ok: false, errors: v.errors };
245
+ }
246
+
247
+ /** Which agents apply to jobs — the pool a job board recruits from. */
248
+ export function poolFor(agents, surface = 'jobs') {
249
+ return (Array.isArray(agents) ? agents : []).filter((a) => a && a.enabled !== false && (Array.isArray(a.appliesTo) ? a.appliesTo : ['jobs']).includes(surface));
250
+ }
package/src/job.js CHANGED
@@ -97,12 +97,13 @@ export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
97
97
  export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
98
98
 
99
99
  /** Applications are computed, not asked for: every eligible type in the pool applies at once. */
100
- export function applyAll(job, pool, fitFn, { cards = {} } = {}) {
101
- const now = Date.now();
100
+ export function applyAll(job, pool, fitFn, { cards = {}, now = Date.now() } = {}) {
101
+ // A fit function may also say which ENGINE the agent would run on (recruit.js does); an
102
+ // application without one is not recruitable right now and sorts after those that are.
102
103
  return (pool || [])
103
104
  .filter((a) => a && a.enabled !== false && (a.appliesTo || ['jobs']).includes('jobs'))
104
- .map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
105
- .sort((x, y) => y.fit - x.fit)
105
+ .map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, ...(f.engine ? { engine: f.engine } : {}), fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
106
+ .sort((x, y) => (!!y.engine - !!x.engine) || (y.fit - x.fit))
106
107
  .slice(0, MAX_APPLICATIONS);
107
108
  }
108
109
 
@@ -197,9 +197,33 @@ export function summarizeEngine(entries, { minCalls = DEFAULT_MIN_CALLS, now = D
197
197
  };
198
198
  }
199
199
 
200
- // `cardOverride` and `applyCard` — the card over the name-based guess — live in
201
- // model-candidates.js beside `applyOverride`, the seam they feed; this module stays
202
- // importable by a store that has no router (the gateway vendors it with scorecard.js only).
200
+ /**
201
+ * The override a card yields for model-candidates.js `applyOverride` only the fields it
202
+ * has enough history for. `quality` is the mean rating (for `jobKind` when the card has
203
+ * ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
204
+ * ttft was recorded); `costPer1k` from the price when one is known; `available: false`
205
+ * only while it is declining right now. Returns `{ override, observed }`.
206
+ *
207
+ * Lives here, beside the card it reads, so recruit.js and a store without a router can use
208
+ * it; `applyCard` (the override over the guess) stays in model-candidates.js beside
209
+ * `applyOverride`, the seam it feeds.
210
+ */
211
+ export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
212
+ const override = {}; const observed = [];
213
+ if (!card) return { override, observed };
214
+ const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
215
+ if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
216
+ const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
217
+ if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
218
+ // Six places, not three: a per-1k price is often 0.0004, and rounding it to 0 made a paid model read as free.
219
+ if (card.cost?.per1kIn != null && card.cost?.per1kOut != null) { override.costPer1k = Math.round(((card.cost.per1kIn + card.cost.per1kOut) / 2) * 1e6) / 1e6; observed.push('costPer1k'); }
220
+ if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
221
+ return { override, observed };
222
+ }
223
+
224
+ // `applyCard` — the card over the name-based guess — lives in model-candidates.js beside
225
+ // `applyOverride`, the seam it feeds; this module stays importable by a store that has no
226
+ // router (the gateway vendors it with scorecard.js only).
203
227
  // Agent scores normalised by engine (§13.3) live beside the card they adjust: scorecard.js
204
228
  // `adjustSummary` and `fit(job, type, summary, { qualityOf })`.
205
229
  export { adjustSummary } from './scorecard.js';
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
+ }
package/src/server.js CHANGED
@@ -38,6 +38,7 @@ import { createTeamStore, loadOrCreateKey as loadTeamKey } from './team-store.js
38
38
  import { createScorecardStore } from './scorecard-store.js';
39
39
  import { createEngineLedgerStore } from './engine-ledger-store.js';
40
40
  import { createProjectStore } from './project-store.js';
41
+ import { applicationsFor, recruitPass } from './recruiting.js';
41
42
  import { createHistoryStore } from './sqlite-store.js';
42
43
  import { ingestBackups } from './backup-ingest.js';
43
44
  import * as nerEngine from './ner-engine.js';
@@ -61,7 +62,7 @@ import * as openai from './openai.js';
61
62
  import * as responses from './responses.js';
62
63
  import * as anthropic from './anthropic.js';
63
64
 
64
- export const VERSION = '0.6.90';
65
+ export const VERSION = '0.6.91';
65
66
 
66
67
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
67
68
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -844,6 +845,11 @@ export function createGateway(cfg = loadConfig()) {
844
845
  // POST /v1/projects/:id/events { events } → { ok, project } the executive loop appends (status, run.linked, run.spent, decision, report)
845
846
  // POST /v1/projects/:id/jobs { job, by } → { ok, project } post a job
846
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
847
853
  // GET /v1/projects/:id/events[?after] (SSE) hello, replay, then live
848
854
  // DELETE /v1/projects/:id
849
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') || '' }) });
@@ -855,10 +861,37 @@ export function createGateway(cfg = loadConfig()) {
855
861
  } catch (e) { return sendJson(res, 400, { error: { message: `project: ${e.message}`, type: 'project_error' } }); }
856
862
  }
857
863
  {
858
- const m = /^\/v1\/projects\/([a-zA-Z0-9_-]{1,64})(\/events|\/jobs(?:\/([a-zA-Z0-9_-]{1,64}))?)?$/.exec(pathname);
864
+ const m = /^\/v1\/projects\/([a-zA-Z0-9_-]{1,64})(\/events|\/jobs(?:\/([a-zA-Z0-9_-]{1,64})(\/applications|\/recruit)?)?)?$/.exec(pathname);
859
865
  if (m) {
860
- const id = m[1]; const sub = m[2] || ''; const jobId = m[3] || '';
866
+ const id = m[1]; const sub = m[2] || ''; const jobId = m[3] || ''; const act = m[4] || '';
861
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
+ }
862
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(); }
863
896
  if (!sub && req.method === 'DELETE') return sendJson(res, 200, { ok: true, removed: projectStore.remove(id) });
864
897
  if (req.method === 'POST' && (sub === '/events' || sub.startsWith('/jobs'))) {