@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/engine.js ADDED
@@ -0,0 +1,132 @@
1
+ // VENDORED from @chatpanel/events/engine.js — edit there, then copy over.
2
+ // An ENGINE — what actually runs an agent's turns — as a declaration, and its one-line label.
3
+ //
4
+ // The model is a variable, not a constant (architecture-pillars.md §13). An agent card names
5
+ // the engine it runs on, and there are four ways to say it:
6
+ //
7
+ // { kind: 'model', providerId?, model } an endpoint the client calls; `providerId` is
8
+ // the endpoint / gateway destination, because the
9
+ // same model at two providers is two engines
10
+ // { kind: 'harness', harnessId, model? } a CLI coding agent the bridge runs (Claude
11
+ // Code, Codex, …) — ChatPanel delegates a whole
12
+ // task to it; `model` is what it was asked to run
13
+ // { kind: 'auto', policy } the recruiter picks, by policy, from the
14
+ // engine cards (§13.4) — the default
15
+ // { kind: 'assistant' } the built-in Assistant: whatever model the chat
16
+ // is on right now (`engineOf` resolves it)
17
+ //
18
+ // The RECORD keeps a flatter shape — `{ kind: 'model'|'harness', id, model? }`, see
19
+ // scorecard.js `normalizeEngine` — because a record says what DID run, and `auto` and
20
+ // `assistant` never run anything themselves. `engineRef` maps a spec to that shape once a
21
+ // choice was made, so the scorecard's `byEngine` and the model ledger key the same way.
22
+ //
23
+ // Pure, dependency-free; team.js and agent.js both import from here, never from each other.
24
+
25
+ export const ENGINE_KINDS = Object.freeze(['model', 'harness', 'auto', 'assistant']);
26
+ export const ROUTE_PREFERS = Object.freeze(['cheapest-that-clears', 'best-quality', 'fastest', 'balanced']);
27
+ export const HARNESS_ID_RE = /^[a-zA-Z0-9_.:@+-]{1,120}$/;
28
+
29
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
30
+ const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).trim().slice(0, n) || undefined);
31
+ const num = (v) => { const n = Number(v); return v === '' || v == null || !Number.isFinite(n) ? undefined : n; };
32
+ const refs = (xs) => (Array.isArray(xs) ? [...new Set(xs.map((x) => str(typeof x === 'string' ? x : engineKeyOf(x), 200)).filter(Boolean))] : undefined);
33
+
34
+ /** A routing policy, normalized: an unknown preference is `balanced`; floors and ceilings are numbers or absent. */
35
+ export function normalizePolicy(p) {
36
+ const src = isRecord(p) ? p : {};
37
+ const floor = {}; const ceiling = {};
38
+ const q = num(src.floor?.quality); if (q !== undefined) floor.quality = Math.max(0, Math.min(1, q));
39
+ const av = num(src.floor?.availability); if (av !== undefined) floor.availability = Math.max(0, Math.min(1, av));
40
+ const c = num(src.ceiling?.costPerTask); if (c !== undefined && c >= 0) ceiling.costPerTask = c;
41
+ const l = num(src.ceiling?.latencyMs); if (l !== undefined && l >= 0) ceiling.latencyMs = Math.round(l);
42
+ const allow = refs(src.allow); const deny = refs(src.deny);
43
+ return {
44
+ prefer: ROUTE_PREFERS.includes(src.prefer) ? src.prefer : 'balanced',
45
+ ...(Object.keys(floor).length ? { floor } : {}),
46
+ ...(Object.keys(ceiling).length ? { ceiling } : {}),
47
+ ...(allow?.length ? { allow } : {}),
48
+ ...(deny?.length ? { deny } : {}),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * An engine spec as stored. A string is read the obvious way — `assistant`, `auto`, a
54
+ * `harness:<id>` / `model:<id>` prefix, or a bare model id — because a person types these
55
+ * and a model proposes them in prose. Anything unreadable is `auto`, the honest default.
56
+ */
57
+ export function normalizeEngineSpec(e) {
58
+ if (e == null || e === '') return { kind: 'auto', policy: normalizePolicy() };
59
+ if (typeof e === 'string') {
60
+ const s = e.trim();
61
+ if (s === 'assistant' || s === 'auto') return s === 'assistant' ? { kind: 'assistant' } : { kind: 'auto', policy: normalizePolicy() };
62
+ const m = /^(model|harness):(.+)$/.exec(s);
63
+ if (m) return m[1] === 'harness' ? { kind: 'harness', harnessId: m[2].trim() } : { kind: 'model', model: m[2].trim() };
64
+ return { kind: 'model', model: s };
65
+ }
66
+ if (!isRecord(e)) return { kind: 'auto', policy: normalizePolicy() };
67
+ const kind = ENGINE_KINDS.includes(e.kind) ? e.kind : (e.harnessId ? 'harness' : e.model ? 'model' : e.policy ? 'auto' : 'auto');
68
+ if (kind === 'assistant') return { kind };
69
+ if (kind === 'auto') return { kind, policy: normalizePolicy(e.policy) };
70
+ if (kind === 'harness') {
71
+ const harnessId = str(e.harnessId || e.id, 120);
72
+ if (!harnessId) return { kind: 'auto', policy: normalizePolicy() };
73
+ const model = str(e.model, 200);
74
+ return { kind, harnessId, ...(model ? { model } : {}) };
75
+ }
76
+ const model = str(e.model || e.id, 200);
77
+ if (!model) return { kind: 'auto', policy: normalizePolicy() };
78
+ const providerId = str(e.providerId || e.destination || e.endpointId, 120);
79
+ return { kind: 'model', ...(providerId ? { providerId } : {}), model };
80
+ }
81
+
82
+ /** Is this a spec a validator should accept? Returns the errors, with a prefix. */
83
+ export function validateEngineSpec(e, where = 'engine') {
84
+ const errors = [];
85
+ if (e == null || e === '') return errors;
86
+ if (typeof e === 'string') return errors; // every string reads as something
87
+ if (!isRecord(e)) return [`${where}: a string or an object`];
88
+ if (e.kind !== undefined && !ENGINE_KINDS.includes(e.kind)) errors.push(`${where}.kind: one of ${ENGINE_KINDS.join(', ')}`);
89
+ if (e.kind === 'harness' && !str(e.harnessId || e.id)) errors.push(`${where}.harnessId: which harness`);
90
+ if (e.kind === 'harness' && str(e.harnessId || e.id) && !HARNESS_ID_RE.test(String(e.harnessId || e.id).trim())) errors.push(`${where}.harnessId: a short identifier`);
91
+ if (e.kind === 'model' && !str(e.model || e.id)) errors.push(`${where}.model: which model`);
92
+ if (e.kind === 'auto' && e.policy !== undefined && !isRecord(e.policy)) errors.push(`${where}.policy: an object`);
93
+ if (e.kind === 'auto' && isRecord(e.policy) && e.policy.prefer !== undefined && !ROUTE_PREFERS.includes(e.policy.prefer)) errors.push(`${where}.policy.prefer: one of ${ROUTE_PREFERS.join(', ')}`);
94
+ return errors;
95
+ }
96
+
97
+ /**
98
+ * The record's shape for a spec that names something concrete — `{ kind, id, model? }`,
99
+ * the same fields scorecard.js keys `byEngine` on and the model ledger is keyed by. `auto`
100
+ * and `assistant` have no ref: nothing ran yet.
101
+ */
102
+ export function engineRef(spec) {
103
+ const s = normalizeEngineSpec(spec);
104
+ if (s.kind === 'harness') return { kind: 'harness', id: s.harnessId, ...(s.model ? { model: s.model } : {}) };
105
+ if (s.kind === 'model') return s.providerId ? { kind: 'model', id: s.providerId, model: s.model } : { kind: 'model', id: s.model };
106
+ return null;
107
+ }
108
+
109
+ /** The ledger key of a spec, or null when it names nothing concrete. Same key as `engineKey` in scorecard.js. */
110
+ export function engineKeyOf(spec) {
111
+ const r = engineRef(spec);
112
+ return r ? `${r.kind}:${r.id}${r.model && r.model !== r.id ? `/${r.model}` : ''}` : null;
113
+ }
114
+
115
+ /** One phrase a person reads on a card: "Claude Code", "gpt-4o at openrouter", "auto · cheapest that clears", "the chat's model". */
116
+ export function describeEngine(spec, { harnessName = (id) => id, providerName = (id) => id } = {}) {
117
+ const s = normalizeEngineSpec(spec);
118
+ if (s.kind === 'assistant') return 'the chat’s model';
119
+ if (s.kind === 'auto') return `auto · ${s.policy.prefer.replace(/-/g, ' ')}`;
120
+ if (s.kind === 'harness') return `${harnessName(s.harnessId)}${s.model ? ` (${s.model})` : ''}`;
121
+ return `${s.model}${s.providerId ? ` at ${providerName(s.providerId)}` : ''}`;
122
+ }
123
+
124
+ /**
125
+ * The role tier today's appointers understand (`cheap` / `balanced` / `strong`) for a spec:
126
+ * the bridge to `prefer` until the recruiter routes by card (§13.4, step 5).
127
+ */
128
+ export function tierOf(spec) {
129
+ const s = normalizeEngineSpec(spec);
130
+ if (s.kind !== 'auto') return 'balanced';
131
+ return { 'best-quality': 'strong', 'cheapest-that-clears': 'cheap', fastest: 'cheap', balanced: 'balanced' }[s.policy.prefer] || 'balanced';
132
+ }
package/src/gate.js ADDED
@@ -0,0 +1,75 @@
1
+ // VENDORED from @chatpanel/events/gate.js — edit there, then copy over.
2
+ // The gate — how far a team may go without a person, as data an organisation configures.
3
+ //
4
+ // ChatPanel's own gate is the strictest setting; an organisation that trusts its pool more
5
+ // flips a flag. Nothing in the runner changes: project-run.js reads the gate at every step
6
+ // where it would otherwise ask, and `gateAllows` is the one question it asks. Lives in
7
+ // `.chatpanel/gate.json` in the org repo (pillars §14.3), optionally overridden per project.
8
+
9
+ export const AUTONOMY = Object.freeze(['propose', 'push', 'merge']);
10
+ export const HUMAN_FLAGS = Object.freeze(['merge', 'push', 'publish', 'budgetRaise', 'newAgent', 'newTool', 'writeBack', 'recruit']);
11
+ export const CHECKS = Object.freeze(['guard', 'review', 'tester', 'scan']);
12
+
13
+ /** ChatPanel's own: a branch push is not a release; everything else waits for a person. */
14
+ export const DEFAULT_GATE = Object.freeze({
15
+ autonomy: 'push',
16
+ human: Object.freeze({ merge: true, push: false, publish: true, budgetRaise: true, newAgent: true, newTool: true, writeBack: true, recruit: false }),
17
+ requiredBeforeMerge: Object.freeze(['guard', 'review', 'tester']),
18
+ branches: Object.freeze({ base: 'main', protected: Object.freeze(['main']) }),
19
+ budget: Object.freeze({ perProjectCap: null, perJobCap: null }),
20
+ });
21
+
22
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
23
+
24
+ export function validateGate(g, { partial = false } = {}) {
25
+ const errors = [];
26
+ if (!isRecord(g)) return { ok: false, errors: ['gate must be an object'] };
27
+ if (g.autonomy !== undefined && !AUTONOMY.includes(g.autonomy)) errors.push(`autonomy: one of ${AUTONOMY.join(', ')}`);
28
+ if (g.human !== undefined) {
29
+ if (!isRecord(g.human)) errors.push('human: an object of flags');
30
+ else for (const [k, v] of Object.entries(g.human)) { if (!HUMAN_FLAGS.includes(k)) errors.push(`human.${k}: unknown flag`); else if (typeof v !== 'boolean') errors.push(`human.${k}: true or false`); }
31
+ }
32
+ if (g.requiredBeforeMerge !== undefined && (!Array.isArray(g.requiredBeforeMerge) || g.requiredBeforeMerge.some((c) => !CHECKS.includes(c)))) errors.push(`requiredBeforeMerge: a list of ${CHECKS.join(', ')}`);
33
+ if (g.branches !== undefined && (!isRecord(g.branches) || (g.branches.protected !== undefined && !Array.isArray(g.branches.protected)))) errors.push('branches: { base, protected[] }');
34
+ if (g.budget !== undefined && !isRecord(g.budget)) errors.push('budget: { perProjectCap, perJobCap }');
35
+ if (!partial && g.autonomy === undefined) errors.push('autonomy: required');
36
+ return { ok: errors.length === 0, errors };
37
+ }
38
+
39
+ /** A gate over the default: a partial gate fills in from ChatPanel's own; a full one stands alone. */
40
+ export function normalizeGate(g, { partial = false, base = DEFAULT_GATE } = {}) {
41
+ const v = validateGate(g || {}, { partial: true });
42
+ if (!v.ok) throw new Error(`gate: ${v.errors.join('; ')}`);
43
+ const src = g || {};
44
+ const b = partial ? base : DEFAULT_GATE;
45
+ return {
46
+ autonomy: AUTONOMY.includes(src.autonomy) ? src.autonomy : b.autonomy,
47
+ human: { ...b.human, ...(isRecord(src.human) ? src.human : {}) },
48
+ requiredBeforeMerge: Array.isArray(src.requiredBeforeMerge) ? [...new Set(src.requiredBeforeMerge)] : [...b.requiredBeforeMerge],
49
+ branches: { base: String(src.branches?.base || b.branches.base), protected: Array.isArray(src.branches?.protected) ? [...new Set(src.branches.protected.map(String))] : [...b.branches.protected] },
50
+ budget: { perProjectCap: src.budget?.perProjectCap ?? b.budget.perProjectCap, perJobCap: src.budget?.perJobCap ?? b.budget.perJobCap },
51
+ };
52
+ }
53
+
54
+ /** The org's gate with a project's partial one over it. */
55
+ export function effectiveGate(orgGate = null, projectGate = null) {
56
+ const org = normalizeGate(orgGate || {}, { partial: true });
57
+ return projectGate ? normalizeGate(projectGate, { partial: true, base: org }) : org;
58
+ }
59
+
60
+ /**
61
+ * The one question the executive loop asks: may a team do `action` on its own?
62
+ * push · merge · publish · budgetRaise · newAgent · newTool · writeBack · recruit
63
+ * Returns `{ allowed, reason }`; a false answer is where the loop asks a person instead.
64
+ */
65
+ export function gateAllows(gate, action, { branch = null } = {}) {
66
+ const g = normalizeGate(gate || {}, { partial: true });
67
+ if (action === 'push' || action === 'merge') {
68
+ if (branch && g.branches.protected.includes(branch)) return { allowed: false, reason: `${branch} is protected — a person merges` };
69
+ const far = AUTONOMY.indexOf(g.autonomy);
70
+ if (action === 'push' && far < AUTONOMY.indexOf('push')) return { allowed: false, reason: 'the gate allows proposing only' };
71
+ if (action === 'merge' && far < AUTONOMY.indexOf('merge')) return { allowed: false, reason: `the gate allows up to ${g.autonomy}` };
72
+ }
73
+ if (HUMAN_FLAGS.includes(action) && g.human[action]) return { allowed: false, reason: `a person decides ${action}` };
74
+ return { allowed: true, reason: `the gate allows ${action}` };
75
+ }
package/src/job.js ADDED
@@ -0,0 +1,150 @@
1
+ // VENDORED from @chatpanel/events/job.js — edit there, then copy over.
2
+ // A job — a posting on a project's board that the pool applies to.
3
+ //
4
+ // The executive posts the first jobs; a recruited agent posts more when the work needs more
5
+ // hands, a skill or a tool it does not have; a person posts one by hand. A job says what it
6
+ // needs (skills, tools, grants), what it may cost (carved from the project's budget), and
7
+ // where the work happens (a repo and a base branch — the bridge gives it a worktree). Agents
8
+ // in the pool APPLY by construction (recruit.js scores every type at once); an evaluator
9
+ // picks; the pick is recruited with a budget and the job becomes a role on a run.
10
+ //
11
+ // A job's status is a machine: open → evaluating → recruited → in-progress → done | failed,
12
+ // with withdrawn from any of the first three. Every move is an event on the project's
13
+ // record (project.js foldProject: `job.posted`, `job.updated`).
14
+
15
+ import { validateBudget, normalizeBudget } from './budget.js';
16
+ import { GRANT_RE } from './team.js';
17
+
18
+ export const JOB_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
19
+ export const JOB_STATUSES = Object.freeze(['open', 'evaluating', 'recruited', 'in-progress', 'done', 'failed', 'withdrawn']);
20
+ const NEXT = Object.freeze({
21
+ open: ['evaluating', 'recruited', 'withdrawn'], evaluating: ['recruited', 'open', 'withdrawn'], recruited: ['in-progress', 'open', 'withdrawn'],
22
+ 'in-progress': ['done', 'failed', 'open'], done: [], failed: ['open'], withdrawn: ['open'],
23
+ });
24
+ export const MAX_NEEDS = 24;
25
+ export const MAX_APPLICATIONS = 64;
26
+
27
+ export class JobError extends Error {
28
+ constructor(code, message) { super(message); this.name = 'JobError'; this.code = code; }
29
+ }
30
+
31
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
33
+ const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => clip(x, n)).filter(Boolean))].slice(0, max);
34
+
35
+ export function validateJob(j) {
36
+ const errors = [];
37
+ if (!isRecord(j)) return { ok: false, errors: ['job must be an object'] };
38
+ if (!JOB_ID_RE.test(String(j.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
39
+ if (!JOB_ID_RE.test(String(j.projectId || ''))) errors.push('projectId: the project this job belongs to');
40
+ if (!clip(j.title, 200)) errors.push('title: what the job is');
41
+ if (!clip(j.brief, 8000)) errors.push('brief: what to do, what done looks like');
42
+ if (j.needs !== undefined) {
43
+ if (!isRecord(j.needs)) errors.push('needs: { skills[], tools[], grants[] }');
44
+ else {
45
+ const badGrants = (Array.isArray(j.needs.grants) ? j.needs.grants : []).filter((g) => !GRANT_RE.test(String(g)));
46
+ if (badGrants.length) errors.push(`needs.grants: not grantable: ${badGrants.join(', ')}`);
47
+ }
48
+ }
49
+ if (j.budget !== undefined) { const b = validateBudget(j.budget); if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`)); }
50
+ if (j.status !== undefined && !JOB_STATUSES.includes(j.status)) errors.push(`status: one of ${JOB_STATUSES.join(', ')}`);
51
+ if (j.dependsOn !== undefined && !Array.isArray(j.dependsOn)) errors.push('dependsOn: a list of job ids');
52
+ if (j.workspace !== undefined && j.workspace !== null && !isRecord(j.workspace)) errors.push('workspace: { repoId, base, branch? }');
53
+ if (j.deadline !== undefined && j.deadline !== null && !Number.isFinite(Number(j.deadline))) errors.push('deadline: a time');
54
+ return { ok: errors.length === 0, errors };
55
+ }
56
+
57
+ export function normalizeJob(j) {
58
+ const v = validateJob(j);
59
+ if (!v.ok) throw new JobError('INVALID', v.errors.join('; '));
60
+ return {
61
+ id: String(j.id),
62
+ projectId: String(j.projectId),
63
+ title: clip(j.title, 200),
64
+ brief: clip(j.brief, 8000),
65
+ needs: {
66
+ skills: list(j.needs?.skills, 80, MAX_NEEDS),
67
+ tools: list(j.needs?.tools, 120, MAX_NEEDS),
68
+ grants: list(j.needs?.grants, 64, MAX_NEEDS).filter((g) => GRANT_RE.test(g)),
69
+ },
70
+ ...(j.budget ? { budget: normalizeBudget(j.budget) } : {}),
71
+ size: { steps: Math.max(0, Math.round(Number(j.size?.steps) || 0)) },
72
+ status: JOB_STATUSES.includes(j.status) ? j.status : 'open',
73
+ postedBy: clip(j.postedBy, 80) || 'person',
74
+ postedAt: Number(j.postedAt) || Date.now(),
75
+ ...(j.deadline ? { deadline: Number(j.deadline) } : {}),
76
+ dependsOn: list(j.dependsOn, 64, 32).filter((d) => d !== j.id),
77
+ ...(j.workspace ? { workspace: { repoId: clip(j.workspace.repoId, 120), base: clip(j.workspace.base, 120) || 'main', ...(j.workspace.branch ? { branch: clip(j.workspace.branch, 200) } : {}), ...(j.workspace.worktreePath ? { worktreePath: clip(j.workspace.worktreePath, 400) } : {}) } } : {}),
78
+ applications: Array.isArray(j.applications) ? j.applications.slice(0, MAX_APPLICATIONS).map(normalizeApplication).filter(Boolean) : [],
79
+ ...(j.recruited ? { recruited: normalizeRecruit(j.recruited) } : {}),
80
+ ...(j.runId ? { runId: String(j.runId) } : {}),
81
+ ...(j.result ? { result: { text: clip(j.result.text, 8000), by: clip(j.result.by, 80), at: Number(j.result.at) || Date.now(), ...(Array.isArray(j.result.refs) ? { refs: j.result.refs.slice(0, 12) } : {}) } } : {}),
82
+ ...(j.origin && isRecord(j.origin) ? { origin: { ...j.origin } } : {}),
83
+ };
84
+ }
85
+
86
+ function normalizeApplication(a) {
87
+ if (!isRecord(a) || !a.agentId) return null;
88
+ return { agentId: String(a.agentId), ...(a.engine ? { engine: a.engine } : {}), fit: Math.max(0, Math.min(1, Number(a.fit) || 0)), reasons: Array.isArray(a.reasons) ? a.reasons.map((r) => clip(r, 200)).slice(0, 8) : [], pitch: clip(a.pitch, 600), at: Number(a.at) || Date.now() };
89
+ }
90
+ function normalizeRecruit(r) {
91
+ return { agentId: String(r.agentId), ...(r.engine ? { engine: r.engine } : {}), ...(r.budget ? { budget: normalizeBudget(r.budget) } : {}), by: clip(r.by, 80) || 'evaluator', at: Number(r.at) || Date.now(), ...(r.why ? { why: clip(r.why, 600) } : {}) };
92
+ }
93
+
94
+ export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
95
+
96
+ /** May the job move from `from` to `to`? */
97
+ export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
98
+
99
+ /** Applications are computed, not asked for: every eligible type in the pool applies at once. */
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.
103
+ return (pool || [])
104
+ .filter((a) => a && a.enabled !== false && (a.appliesTo || ['jobs']).includes('jobs'))
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))
107
+ .slice(0, MAX_APPLICATIONS);
108
+ }
109
+
110
+ /** A recruited job as the role a run gives the agent: the brief is the prompt, the needs are the grants. */
111
+ export function jobToRole(job, agent) {
112
+ return {
113
+ id: agent?.id || job.id,
114
+ agent: agent?.id,
115
+ name: agent?.name || job.title,
116
+ prompt: [agent?.prompt, `Job: ${job.title}\n\n${job.brief}`].filter(Boolean).join('\n\n'),
117
+ grants: (job.needs?.grants?.length ? job.needs.grants : agent?.grants) || ['none'],
118
+ ...(job.dependsOn?.length ? { dependsOn: job.dependsOn } : {}),
119
+ ...(job.workspace ? { workspace: job.workspace } : {}),
120
+ };
121
+ }
122
+
123
+ /** The jobs that may run now: every dependency done, and not already taken. */
124
+ export function readyJobs(jobs) {
125
+ const byId = new Map((jobs || []).map((j) => [j.id, j]));
126
+ return (jobs || []).filter((j) => j.status === 'open' && (j.dependsOn || []).every((d) => byId.get(d)?.status === 'done'));
127
+ }
128
+
129
+ /** A blank posting for the form. */
130
+ export function blankJob(projectId = '') {
131
+ return { id: '', projectId, title: '', brief: '', needs: { skills: [], tools: [], grants: [] }, budget: { tokens: 40000, ms: 900000 }, status: 'open', dependsOn: [] };
132
+ }
133
+
134
+ export function jobFromForm(form) {
135
+ const budget = {};
136
+ for (const k of ['tokens', 'calls', 'ms', 'usd']) {
137
+ const v = Number(form?.budget?.[k]);
138
+ if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
139
+ }
140
+ const j = {
141
+ id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
142
+ projectId: form?.projectId, title: form?.title, brief: form?.brief,
143
+ needs: { skills: form?.needs?.skills, tools: form?.needs?.tools, grants: form?.needs?.grants },
144
+ ...(Object.keys(budget).length ? { budget } : {}),
145
+ status: form?.status || 'open', postedBy: form?.postedBy || 'person', dependsOn: form?.dependsOn,
146
+ ...(form?.workspace?.repoId ? { workspace: form.workspace } : {}), ...(form?.size ? { size: form.size } : {}),
147
+ };
148
+ const v = validateJob(j);
149
+ return v.ok ? { ok: true, job: normalizeJob(j) } : { ok: false, errors: v.errors };
150
+ }
@@ -0,0 +1,229 @@
1
+ // VENDORED from @chatpanel/events/model-ledger.js — edit there, then copy over.
2
+ // The MODEL LEDGER — an engine's record, the scorecard pattern applied to engines
3
+ // (architecture-pillars.md §13.2).
4
+ //
5
+ // One chained, store-attested ledger per ENGINE — keyed like the scorecard's `byEngine`
6
+ // (`model:<provider>/<model>`, `harness:<id>`), because the same model at two providers is
7
+ // two records: availability, cost and latency are the provider's, not the model's. Entries
8
+ // are FACTS the runner and the gateway observe, never claims:
9
+ //
10
+ // call one turn: time to first token, total, tokens in/out, cost when priced, was
11
+ // the JSON valid, were the tool calls valid, did it come back empty / refused
12
+ // / truncated, did it succeed
13
+ // declined it did not answer, and why (unavailable · auth · rate · credits · timeout ·
14
+ // context) — the availability signal
15
+ // rotated-from a task left it for another engine mid-run
16
+ // rating a task's verdict, attributed to the engine that served it (and to the agent)
17
+ // capability a proof: it was asked for X and it did / did not deliver
18
+ // price what a token costs here — from the provider's list or typed by a person
19
+ //
20
+ // `summarizeEngine(entries)` → the ENGINE CARD: availability, reliability, latency, cost,
21
+ // capability proofs (a capability with three failed proofs is WITHDRAWN until a person
22
+ // re-enables it), quality by job kind, the last refs. model-candidates.js `applyCard` hands
23
+ // the card to `applyOverride`: observed quality / latency / cost replace the name-based
24
+ // guess wherever there is enough history (≥ `minCalls`), the guess stays as the prior until
25
+ // then, and the result says which it used (`observed[]`). Reach is never learned, only
26
+ // typed — a ledger cannot move a model closer than the URL says.
27
+ //
28
+ // Hashing, attestation and chain verification are scorecard.js's, unchanged: the same store
29
+ // marks both, the same `verifyChain` checks both.
30
+
31
+ import { canonical, sha256, engineKey, normalizeEngine } from './scorecard.js';
32
+ export { verifyChain, attest, verifyAttested } from './scorecard.js';
33
+
34
+ export const LEDGER_VERSION = 1;
35
+ export const LEDGER_ENTRY_KINDS = Object.freeze(['call', 'declined', 'rotated-from', 'rating', 'capability', 'price']);
36
+ export const DECLINE_REASONS = Object.freeze(['unavailable', 'auth', 'rate', 'credits', 'timeout', 'context', 'other']);
37
+ export const STRUCTURED = Object.freeze(['ok', 'bad', 'n/a']);
38
+ /** Failed proofs before a capability leaves the card. */
39
+ export const WITHDRAW_AFTER = 3;
40
+ /** Calls before an observed number outranks the name-based guess. Small; configurable. */
41
+ export const DEFAULT_MIN_CALLS = 5;
42
+
43
+ const n0 = (v) => Math.max(0, Math.round(Number(v) || 0));
44
+ const money = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? undefined : Math.max(0, Number(v)));
45
+ const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
46
+ const bool = (v) => v === true;
47
+ const str = (v, n = 120) => (v == null || v === '' ? undefined : String(v).slice(0, n));
48
+ const strip = (o) => { for (const k of Object.keys(o)) if (o[k] === undefined) delete o[k]; return o; };
49
+
50
+ /** The ledger's key for an engine — the scorecard's `engineKey`, so the two join. */
51
+ export function ledgerKey(engine) { return engineKey(engine); }
52
+
53
+ /** A call fact, normalized. Rates are computed later; here every field is a plain observation. */
54
+ export function normalizeCall(c) {
55
+ const src = c && typeof c === 'object' ? c : {};
56
+ return strip({
57
+ ok: src.ok !== false,
58
+ ttftMs: src.ttftMs != null ? n0(src.ttftMs) : undefined,
59
+ totalMs: src.totalMs != null ? n0(src.totalMs) : undefined,
60
+ tokensIn: src.tokensIn != null ? n0(src.tokensIn) : undefined,
61
+ tokensOut: src.tokensOut != null ? n0(src.tokensOut) : undefined,
62
+ // The total when the split is unknown (a harness reports one number, or none).
63
+ tokens: src.tokens != null && src.tokensIn == null && src.tokensOut == null ? n0(src.tokens) : undefined,
64
+ cost: money(src.cost),
65
+ structured: STRUCTURED.includes(src.structured) ? src.structured : 'n/a',
66
+ toolCalls: src.toolCalls && typeof src.toolCalls === 'object' ? { asked: n0(src.toolCalls.asked), valid: Math.min(n0(src.toolCalls.asked), n0(src.toolCalls.valid)) } : undefined,
67
+ empty: bool(src.empty) || undefined,
68
+ refused: bool(src.refused) || undefined,
69
+ truncated: bool(src.truncated) || undefined,
70
+ });
71
+ }
72
+
73
+ /**
74
+ * A new entry chained onto `prev`. `fact.engine` is required and keyed; the rest is by kind.
75
+ * Pure apart from the digest; the store attests.
76
+ */
77
+ export async function makeLedgerEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
78
+ if (!fact || typeof fact !== 'object') throw new Error('model-ledger: an entry needs a fact');
79
+ if (!LEDGER_ENTRY_KINDS.includes(fact.kind)) throw new Error(`model-ledger: kind must be one of ${LEDGER_ENTRY_KINDS.join(', ')}`);
80
+ const engine = normalizeEngine(fact.engine);
81
+ if (!engine) throw new Error('model-ledger: engine required');
82
+ const key = engineKey(engine);
83
+ if (prev && prev.key !== key) throw new Error(`model-ledger: entry for ${key} chained onto ${prev.key}`);
84
+ const e = strip({
85
+ v: LEDGER_VERSION,
86
+ seq: prev ? prev.seq + 1 : 0,
87
+ key,
88
+ engine,
89
+ kind: fact.kind,
90
+ at: Number(fact.at) || now(),
91
+ runId: str(fact.runId), taskId: str(fact.taskId), agentId: str(fact.agentId), jobKind: str(fact.jobKind, 60),
92
+ call: fact.kind === 'call' ? normalizeCall(fact.call) : undefined,
93
+ declined: fact.kind === 'declined' ? { reason: DECLINE_REASONS.includes(fact.declined?.reason) ? fact.declined.reason : 'other', ...(fact.declined?.error ? { error: String(fact.declined.error).slice(0, 300) } : {}) } : undefined,
94
+ rotated: fact.kind === 'rotated-from' ? strip({ to: engineKey(fact.rotated?.to) || undefined, reason: str(fact.rotated?.reason, 300) }) : undefined,
95
+ rating: fact.kind === 'rating' ? strip({ by: String(fact.rating?.by || 'person').slice(0, 40), score: clamp01(fact.rating?.score), jobKind: str(fact.rating?.jobKind || fact.jobKind, 60), agentId: str(fact.rating?.agentId || fact.agentId) }) : undefined,
96
+ capability: fact.kind === 'capability' ? { id: String(fact.capability?.id || '').slice(0, 40), proved: bool(fact.capability?.proved) } : undefined,
97
+ price: fact.kind === 'price' ? strip({ per1kIn: money(fact.price?.per1kIn) ?? 0, per1kOut: money(fact.price?.per1kOut) ?? 0, source: fact.price?.source === 'user' ? 'user' : 'provider', currency: str(fact.price?.currency, 8) }) : undefined,
98
+ refs: Array.isArray(fact.refs) && fact.refs.length ? fact.refs.map(String).slice(0, 12) : undefined,
99
+ prev: prev ? prev.hash : null,
100
+ });
101
+ if (e.kind === 'capability' && !e.capability.id) throw new Error('model-ledger: capability.id required');
102
+ const { hash: _h, sig: _s, ...hashable } = e;
103
+ e.hash = await sha256(canonical(hashable), { subtle });
104
+ return e;
105
+ }
106
+
107
+ const percentile = (xs, p) => {
108
+ if (!xs.length) return null;
109
+ const s = [...xs].sort((a, b) => a - b);
110
+ return s[Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))];
111
+ };
112
+ const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
113
+ const rate = (n, of) => (of ? Math.round((n / of) * 1000) / 1000 : null);
114
+ const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
115
+
116
+ /**
117
+ * The engine card. `minCalls` marks it `observed` once there is enough history; `now`
118
+ * bounds the by-hour availability band (the last 24 h) and the "declining right now" check.
119
+ */
120
+ export function summarizeEngine(entries, { minCalls = DEFAULT_MIN_CALLS, now = Date.now(), recent = 5 } = {}) {
121
+ const list = (entries || []).filter((e) => e && e.kind);
122
+ const calls = list.filter((e) => e.kind === 'call');
123
+ const declines = list.filter((e) => e.kind === 'declined');
124
+ const attempts = calls.length + declines.length;
125
+ // Availability: declines over attempts, and the last 24 hours in bands of one.
126
+ const byHour = Array.from({ length: 24 }, () => ({ calls: 0, declines: 0 }));
127
+ for (const e of [...calls, ...declines]) {
128
+ const h = Math.floor((now - e.at) / 3600000);
129
+ if (h >= 0 && h < 24) byHour[23 - h][e.kind === 'call' ? 'calls' : 'declines'] += 1;
130
+ }
131
+ const declinesBy = {};
132
+ for (const e of declines) declinesBy[e.declined.reason] = (declinesBy[e.declined.reason] || 0) + 1;
133
+ // Declining right now: the last three attempts all declined, within the last hour.
134
+ const lastThree = [...calls, ...declines].sort((a, b) => a.at - b.at).slice(-3);
135
+ const decliningNow = lastThree.length === 3 && lastThree.every((e) => e.kind === 'declined' && now - e.at < 3600000);
136
+ // Reliability: each a rate over the calls it applies to.
137
+ const withJson = calls.filter((e) => e.call.structured !== 'n/a');
138
+ const withTools = calls.filter((e) => e.call.toolCalls?.asked);
139
+ const reliability = {
140
+ failRate: rate(calls.filter((e) => !e.call.ok).length, calls.length),
141
+ empty: rate(calls.filter((e) => e.call.empty).length, calls.length),
142
+ refused: rate(calls.filter((e) => e.call.refused).length, calls.length),
143
+ truncated: rate(calls.filter((e) => e.call.truncated).length, calls.length),
144
+ badJson: rate(withJson.filter((e) => e.call.structured === 'bad').length, withJson.length),
145
+ badToolCall: rate(withTools.reduce((n, e) => n + (e.call.toolCalls.asked - e.call.toolCalls.valid), 0), withTools.reduce((n, e) => n + e.call.toolCalls.asked, 0)),
146
+ };
147
+ // Latency.
148
+ const ttft = calls.map((e) => e.call.ttftMs).filter((v) => v != null);
149
+ const total = calls.map((e) => e.call.totalMs).filter((v) => v != null);
150
+ const latency = { ttft: { p50: percentile(ttft, 50), p95: percentile(ttft, 95), n: ttft.length }, total: { p50: percentile(total, 50), p95: percentile(total, 95), n: total.length } };
151
+ // Cost: the latest price entry prices every call that reported tokens; a call that
152
+ // reported its own cost is taken as is; otherwise the mean tokens per call stands in.
153
+ const price = list.filter((e) => e.kind === 'price').at(-1)?.price || null;
154
+ const costs = calls.map((e) => (e.call.cost != null ? e.call.cost : price && (e.call.tokensIn != null || e.call.tokensOut != null) ? ((e.call.tokensIn || 0) * price.per1kIn + (e.call.tokensOut || 0) * price.per1kOut) / 1000 : null)).filter((v) => v != null);
155
+ const tokens = calls.map((e) => (e.call.tokensIn || 0) + (e.call.tokensOut || 0) + (e.call.tokens || 0)).filter((v) => v > 0);
156
+ const cost = { perTask: r3(mean(costs)), priced: costs.length, tokensPerTask: tokens.length ? Math.round(mean(tokens)) : null, ...(price ? { per1kIn: price.per1kIn, per1kOut: price.per1kOut, source: price.source } : {}) };
157
+ // Capability proofs: asked vs proved; withdrawn after WITHDRAW_AFTER failures unless a
158
+ // later proof succeeded (a person re-enabling it is a proof they record).
159
+ const proofs = {};
160
+ for (const e of list.filter((x) => x.kind === 'capability')) {
161
+ const p = proofs[e.capability.id] || (proofs[e.capability.id] = { asked: 0, proved: 0, failedSince: 0 });
162
+ p.asked += 1;
163
+ if (e.capability.proved) { p.proved += 1; p.failedSince = 0; } else p.failedSince += 1;
164
+ }
165
+ const capabilities = {
166
+ proved: Object.keys(proofs).filter((id) => proofs[id].proved > 0 && proofs[id].failedSince < WITHDRAW_AFTER).sort(),
167
+ withdrawn: Object.keys(proofs).filter((id) => proofs[id].failedSince >= WITHDRAW_AFTER).sort(),
168
+ proofs: Object.fromEntries(Object.entries(proofs).map(([id, p]) => [id, { asked: p.asked, proved: p.proved }])),
169
+ };
170
+ // Quality: mean rating, overall and by job kind.
171
+ const ratings = list.filter((e) => e.kind === 'rating');
172
+ const byJobKind = {};
173
+ for (const e of ratings) { const k = e.rating.jobKind || 'any'; (byJobKind[k] = byJobKind[k] || []).push(e.rating.score); }
174
+ const quality = {
175
+ overall: { avg: r3(mean(ratings.map((e) => e.rating.score))), count: ratings.length },
176
+ byJobKind: Object.fromEntries(Object.entries(byJobKind).map(([k, xs]) => [k, { avg: r3(mean(xs)), count: xs.length }])),
177
+ };
178
+ const rotatedFrom = list.filter((e) => e.kind === 'rotated-from').length;
179
+ return {
180
+ key: list[0]?.key || null,
181
+ engine: list[0]?.engine || null,
182
+ entries: list.length,
183
+ calls: calls.length,
184
+ declines: declines.length,
185
+ observed: calls.length >= minCalls,
186
+ availability: { rate: attempts ? r3(1 - declines.length / attempts) : null, attempts, declinesBy, byHour, decliningNow },
187
+ reliability,
188
+ latency,
189
+ cost,
190
+ capabilities,
191
+ quality,
192
+ rotatedFrom,
193
+ refs: [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent),
194
+ since: list[0]?.at || null,
195
+ last: list.at(-1)?.at || null,
196
+ head: list.at(-1)?.hash || null,
197
+ };
198
+ }
199
+
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).
227
+ // Agent scores normalised by engine (§13.3) live beside the card they adjust: scorecard.js
228
+ // `adjustSummary` and `fit(job, type, summary, { qualityOf })`.
229
+ export { adjustSummary } from './scorecard.js';