@chatpanel/gateway 0.6.87 → 0.6.90

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.
@@ -0,0 +1,205 @@
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
+ // `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).
203
+ // Agent scores normalised by engine (§13.3) live beside the card they adjust: scorecard.js
204
+ // `adjustSummary` and `fit(job, type, summary, { qualityOf })`.
205
+ export { adjustSummary } from './scorecard.js';
@@ -0,0 +1,139 @@
1
+ // The projects — every goal's page and everything done for it, as the record both clients
2
+ // read (F8 §12). The page itself is data in the shared `projects` prefs section; this store
3
+ // holds the RECORD: the jobs posted on it, who applied and who was recruited, the runs it
4
+ // spawned and what they spent, the stakeholder's decisions, the report. Folded from events
5
+ // with the shared fold (project.js `foldProject`, vendored), so the desktop, the extension
6
+ // and this store never disagree on what a project is.
7
+ //
8
+ // Encrypted at rest with the team store's key, like the runs. Events append; nothing is
9
+ // edited; a watcher gets each event as it lands (the live project board).
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import os from 'node:os';
14
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
15
+ import { emptyProjectRecord, foldProject, projectProgress, PROJECT_ID_RE } from './project.js';
16
+ import { normalizeJob, canTransition as canJobTransition } from './job.js';
17
+
18
+ const DIR = join(os.homedir(), '.chatpanel');
19
+ const STORE_PATH = process.env.CHATPANEL_PROJECTS_STORE || join(DIR, 'projects.enc');
20
+ const MAX_EVENTS_PER_PROJECT = 20_000;
21
+ const MAX_EVENT_BYTES = 64 * 1024;
22
+
23
+ function encrypt(key, buf) {
24
+ const iv = randomBytes(12);
25
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
26
+ const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
27
+ return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
28
+ }
29
+ function decrypt(key, env) {
30
+ const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
31
+ d.setAuthTag(Buffer.from(env.tag, 'base64'));
32
+ return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
33
+ }
34
+ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
35
+
36
+ export class ProjectStore {
37
+ constructor({ storePath = STORE_PATH, key = null, now = () => Date.now() } = {}) {
38
+ this.path = storePath;
39
+ this.key = key;
40
+ this.now = now;
41
+ this.projects = new Map(); // id -> { ...record, events: [] }
42
+ this.watchers = new Map(); // id -> Set<fn(ev)>
43
+ }
44
+ load() {
45
+ try {
46
+ if (existsSync(this.path) && this.key) {
47
+ const env = JSON.parse(readFileSync(this.path, 'utf8'));
48
+ const doc = JSON.parse(decrypt(this.key, env).toString('utf8'));
49
+ for (const p of Array.isArray(doc?.projects) ? doc.projects : []) if (p?.id) this.projects.set(p.id, p);
50
+ }
51
+ } catch { this.projects = new Map(); }
52
+ return this;
53
+ }
54
+ save() {
55
+ if (!this.key) return;
56
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
57
+ const env = encrypt(this.key, Buffer.from(JSON.stringify({ v: 1, projects: [...this.projects.values()] }), 'utf8'));
58
+ const tmp = `${this.path}.${process.pid}.tmp`;
59
+ writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
60
+ renameSync(tmp, this.path);
61
+ }
62
+ _view(rec, { events = false } = {}) {
63
+ const v = clone({ ...rec, events: undefined });
64
+ delete v.events;
65
+ v.progress = projectProgress(v);
66
+ if (events) v.events = clone(rec.events);
67
+ return v;
68
+ }
69
+ /** Open a record for a page (idempotent: a page saved twice is one record). */
70
+ create({ id, project = null, by = 'person' } = {}) {
71
+ if (!PROJECT_ID_RE.test(String(id || ''))) throw new Error('project id: a short identifier');
72
+ let rec = this.projects.get(id);
73
+ if (!rec) { rec = { ...emptyProjectRecord({ id, now: this.now() }), events: [] }; this.projects.set(id, rec); }
74
+ if (project) this.append(id, [{ type: rec.page ? 'project.updated' : 'project.created', at: this.now(), project: { ...project, id }, by }]);
75
+ else this.save();
76
+ return this._view(rec);
77
+ }
78
+ append(id, events) {
79
+ const rec = this.projects.get(String(id || ''));
80
+ if (!rec) throw new Error(`no project ${id}`);
81
+ const list = Array.isArray(events) ? events : [events];
82
+ let seq = rec.events.length;
83
+ for (const e of list) {
84
+ if (!e || typeof e !== 'object' || !e.type) continue;
85
+ let bytes; try { bytes = Buffer.byteLength(JSON.stringify(e), 'utf8'); } catch { continue; }
86
+ if (bytes > MAX_EVENT_BYTES) continue;
87
+ if (rec.events.length >= MAX_EVENTS_PER_PROJECT) break;
88
+ const { type, at, ...payload } = e;
89
+ const ev = { seq: seq++, type: String(type), at: Number(at) || this.now(), payload };
90
+ rec.events.push(ev);
91
+ foldProject(rec, ev);
92
+ for (const fn of this.watchers.get(rec.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
93
+ }
94
+ this.save();
95
+ return this._view(rec);
96
+ }
97
+ get(id, opts) { const r = this.projects.get(String(id || '')); return r ? this._view(r, opts) : null; }
98
+ /** Newest activity first; the list a board shows — jobs counted, not listed. */
99
+ list({ limit = 50, status = '' } = {}) {
100
+ return [...this.projects.values()]
101
+ .filter((r) => !status || r.status === status)
102
+ .sort((a, b) => b.lastEventAt - a.lastEventAt)
103
+ .slice(0, Math.max(1, Math.min(200, Number(limit) || 50)))
104
+ .map((r) => { const v = this._view(r); return { ...v, jobs: undefined, decisions: undefined, report: undefined, runs: undefined, jobCount: r.jobs.length, runCount: r.runs.length }; });
105
+ }
106
+ /** Every open posting across projects — the job board. */
107
+ openJobs() {
108
+ return [...this.projects.values()].flatMap((r) => r.jobs.filter((j) => ['open', 'evaluating', 'recruited'].includes(j.status)).map((j) => ({ ...j, projectTitle: r.page?.title || r.id })));
109
+ }
110
+ /** Post a job on a project: validated as a posting, appended as an event. */
111
+ postJob(id, job, { by = 'person' } = {}) {
112
+ const rec = this.projects.get(String(id || ''));
113
+ if (!rec) throw new Error(`no project ${id}`);
114
+ const j = normalizeJob({ ...job, projectId: rec.id, postedBy: job?.postedBy || by });
115
+ if (rec.jobs.some((x) => x.id === j.id)) throw new Error(`job ${j.id} already exists`);
116
+ return this.append(rec.id, [{ type: 'job.posted', at: this.now(), job: j, by }]);
117
+ }
118
+ /** Move a job along its machine, or update its fields (applications, recruited, result). */
119
+ updateJob(id, jobId, patch, { by = 'person' } = {}) {
120
+ const rec = this.projects.get(String(id || ''));
121
+ if (!rec) throw new Error(`no project ${id}`);
122
+ const cur = rec.jobs.find((x) => x.id === jobId);
123
+ if (!cur) throw new Error(`no job ${jobId}`);
124
+ if (patch?.status && patch.status !== cur.status && !canJobTransition(cur.status, patch.status)) throw new Error(`a job cannot go from ${cur.status} to ${patch.status}`);
125
+ const allowed = ['status', 'applications', 'recruited', 'runId', 'result', 'workspace', 'budget', 'brief', 'title', 'needs', 'dependsOn'];
126
+ const job = { id: jobId };
127
+ for (const k of allowed) if (patch?.[k] !== undefined) job[k] = patch[k];
128
+ return this.append(rec.id, [{ type: 'job.updated', at: this.now(), job, by }]);
129
+ }
130
+ watch(id, fn) {
131
+ const set = this.watchers.get(String(id)) || new Set();
132
+ set.add(fn); this.watchers.set(String(id), set);
133
+ return () => { set.delete(fn); };
134
+ }
135
+ eventsSince(id, after = -1) { const r = this.projects.get(String(id || '')); return r ? r.events.filter((e) => e.seq > after) : []; }
136
+ remove(id) { const had = this.projects.delete(String(id || '')); if (had) this.save(); return had; }
137
+ }
138
+
139
+ export function createProjectStore(opts) { return new ProjectStore(opts).load(); }
package/src/project.js ADDED
@@ -0,0 +1,171 @@
1
+ // VENDORED from @chatpanel/events/project.js — edit there, then copy over.
2
+ // A project — the page a goal starts on, and the record that folds everything done for it.
3
+ //
4
+ // Every job or goal starts here (F8 §12): the goal, defined by its stakeholder — the *chief
5
+ // executive*: a person by default, an agent from the pool when the person delegates it —
6
+ // with a done-when a run can be checked against, a budget the jobs are carved from, the
7
+ // repos the work happens in, and the gate that says how far a team may go without a person.
8
+ // A project is data (a `projects` prefs section, both clients) and a gateway record that
9
+ // folds its jobs, runs and spend from events, the way a run folds (team-record.js).
10
+ //
11
+ // A project's status is a small machine: draft (a goal being written) → open (jobs may be
12
+ // posted) → active (a job was recruited) → done (done-when held, a person closed it) or
13
+ // closed (abandoned). Nothing here runs anything: project-run.js is the executive loop.
14
+
15
+ import { validateBudget, normalizeBudget } from './budget.js';
16
+ import { normalizeGate, validateGate } from './gate.js';
17
+
18
+ export const PROJECT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
19
+ export const PROJECT_STATUSES = Object.freeze(['draft', 'open', 'active', 'done', 'closed']);
20
+ const NEXT = Object.freeze({ draft: ['open', 'closed'], open: ['active', 'closed', 'draft'], active: ['done', 'closed', 'open'], done: ['closed', 'open'], closed: ['open'] });
21
+ export const MAX_REPOS = 16;
22
+
23
+ export class ProjectError extends Error {
24
+ constructor(code, message) { super(message); this.name = 'ProjectError'; this.code = code; }
25
+ }
26
+
27
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
28
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
29
+
30
+ export function validateProject(p) {
31
+ const errors = [];
32
+ if (!isRecord(p)) return { ok: false, errors: ['project must be an object'] };
33
+ if (!PROJECT_ID_RE.test(String(p.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
34
+ if (!clip(p.title, 200)) errors.push('title: what the project is called');
35
+ if (!clip(p.goal, 4000)) errors.push('goal: what done looks like, in the stakeholder\'s words');
36
+ if (p.doneWhen !== undefined && !clip(p.doneWhen, 2000)) errors.push('doneWhen: a check a run can be held to, or leave it out');
37
+ if (p.stakeholder !== undefined && p.stakeholder !== 'person' && !/^[a-z][a-z0-9_-]{0,63}$/i.test(String(p.stakeholder))) errors.push('stakeholder: "person" or an agent id');
38
+ if (p.status !== undefined && !PROJECT_STATUSES.includes(p.status)) errors.push(`status: one of ${PROJECT_STATUSES.join(', ')}`);
39
+ const b = validateBudget(p.budget);
40
+ if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`));
41
+ if (p.repos !== undefined) {
42
+ if (!Array.isArray(p.repos)) errors.push('repos: a list of repo ids');
43
+ else if (p.repos.length > MAX_REPOS) errors.push(`repos: at most ${MAX_REPOS}`);
44
+ }
45
+ if (p.gate !== undefined && p.gate !== null) errors.push(...validateGate(p.gate, { partial: true }).errors.map((e) => `gate: ${e}`));
46
+ return { ok: errors.length === 0, errors };
47
+ }
48
+
49
+ /** The stored form: defaults filled, the budget normalised, the gate (if any) normalised. */
50
+ export function normalizeProject(p) {
51
+ const v = validateProject(p);
52
+ if (!v.ok) throw new ProjectError('INVALID', v.errors.join('; '));
53
+ return {
54
+ id: String(p.id),
55
+ title: clip(p.title, 200),
56
+ goal: clip(p.goal, 4000),
57
+ doneWhen: clip(p.doneWhen, 2000),
58
+ stakeholder: p.stakeholder ? String(p.stakeholder) : 'person',
59
+ budget: normalizeBudget(p.budget),
60
+ status: PROJECT_STATUSES.includes(p.status) ? p.status : 'draft',
61
+ repos: Array.isArray(p.repos) ? [...new Set(p.repos.map((r) => clip(r, 120)).filter(Boolean))].slice(0, MAX_REPOS) : [],
62
+ ...(p.gate ? { gate: normalizeGate(p.gate, { partial: true }) } : {}),
63
+ tags: Array.isArray(p.tags) ? [...new Set(p.tags.map((t) => clip(t, 40)).filter(Boolean))].slice(0, 12) : [],
64
+ createdBy: clip(p.createdBy, 80) || 'person',
65
+ createdAt: Number(p.createdAt) || Date.now(),
66
+ ...(p.updatedAt ? { updatedAt: Number(p.updatedAt) } : {}),
67
+ };
68
+ }
69
+
70
+ export function defineProject(p) { return Object.freeze(normalizeProject(p)); }
71
+
72
+ /** May the project move from `from` to `to`? The machine above; a person's close is always allowed. */
73
+ export function canTransition(from, to) {
74
+ return (NEXT[from] || []).includes(to);
75
+ }
76
+
77
+ /** A blank page for the form. */
78
+ export function blankProject() {
79
+ return { id: '', title: '', goal: '', doneWhen: '', stakeholder: 'person', budget: { tokens: 200000, ms: 3600000 }, status: 'draft', repos: [], tags: [] };
80
+ }
81
+
82
+ /** The form → a project, or the errors (the same shaping both clients use). */
83
+ export function projectFromForm(form) {
84
+ const budget = {};
85
+ for (const k of ['tokens', 'calls', 'ms', 'usd']) {
86
+ const v = Number(form?.budget?.[k]);
87
+ if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
88
+ }
89
+ const p = {
90
+ id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
91
+ title: form?.title, goal: form?.goal, doneWhen: form?.doneWhen, stakeholder: form?.stakeholder || 'person',
92
+ budget, status: form?.status || 'draft',
93
+ repos: String(Array.isArray(form?.repos) ? form.repos.join(',') : form?.repos || '').split(/[,\s]+/).filter(Boolean),
94
+ tags: String(Array.isArray(form?.tags) ? form.tags.join(',') : form?.tags || '').split(/[,\s]+/).filter(Boolean),
95
+ ...(form?.gate ? { gate: form.gate } : {}), ...(form?.createdAt ? { createdAt: form.createdAt } : {}),
96
+ };
97
+ const v = validateProject(p);
98
+ return v.ok ? { ok: true, project: normalizeProject(p) } : { ok: false, errors: v.errors };
99
+ }
100
+
101
+ // ── the record: a project folded from its events ────────────────────────────────────────
102
+
103
+ /** The empty record — what the gateway holds per project and both clients read. */
104
+ export function emptyProjectRecord({ id, now = Date.now() } = {}) {
105
+ return { id, page: null, status: 'draft', jobs: [], runs: [], spend: { tokens: 0, calls: 0, usd: 0, ms: 0 }, decisions: [], report: null, createdAt: now, lastEventAt: now };
106
+ }
107
+
108
+ /**
109
+ * Fold one event into the record. Events: `project.created` `{ project }` · `project.updated`
110
+ * `{ project }` · `project.status` `{ status, by, note }` · `job.posted` `{ job }` ·
111
+ * `job.updated` `{ job }` (any field: applications, recruited, status) · `run.linked`
112
+ * `{ runId, jobId }` · `run.spent` `{ runId, spent }` · `project.decision` `{ by, kind, text,
113
+ * refs }` · `project.report` `{ text, by }`. Idempotent by job id and run id.
114
+ */
115
+ export function foldProject(rec, ev) {
116
+ const type = String(ev?.type || '');
117
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
118
+ const at = Number(ev?.at) || Date.now();
119
+ rec.lastEventAt = at;
120
+ switch (type) {
121
+ case 'project.created':
122
+ case 'project.updated':
123
+ if (p.project && typeof p.project === 'object') { rec.page = { ...p.project, updatedAt: at }; rec.status = p.project.status || rec.status; }
124
+ break;
125
+ case 'project.status':
126
+ if (PROJECT_STATUSES.includes(p.status)) { rec.status = p.status; if (rec.page) rec.page.status = p.status; rec.decisions.push({ at, by: p.by || 'person', kind: 'status', text: `${p.status}${p.note ? ` — ${p.note}` : ''}` }); }
127
+ break;
128
+ case 'job.posted':
129
+ if (p.job?.id && !rec.jobs.some((j) => j.id === p.job.id)) rec.jobs.push({ ...p.job, postedAt: at });
130
+ if (rec.status === 'draft') rec.status = 'open';
131
+ break;
132
+ case 'job.updated': {
133
+ const i = rec.jobs.findIndex((j) => j.id === p.job?.id);
134
+ if (i >= 0) rec.jobs[i] = { ...rec.jobs[i], ...p.job, updatedAt: at };
135
+ if (p.job?.status === 'recruited' || p.job?.status === 'in-progress') { if (rec.status === 'open' || rec.status === 'draft') rec.status = 'active'; }
136
+ break;
137
+ }
138
+ case 'run.linked':
139
+ if (p.runId && !rec.runs.some((r) => r.runId === p.runId)) rec.runs.push({ runId: p.runId, jobId: p.jobId || null, at });
140
+ break;
141
+ case 'run.spent': {
142
+ const r = rec.runs.find((x) => x.runId === p.runId);
143
+ const prev = r?.spent || { tokens: 0, calls: 0, usd: 0, ms: 0 };
144
+ const next = { tokens: Number(p.spent?.tokens) || 0, calls: Number(p.spent?.calls) || 0, usd: Number(p.spent?.usd) || 0, ms: Number(p.spent?.ms) || 0 };
145
+ // A run reports its running total; the project's spend is the sum of every run's latest.
146
+ for (const k of Object.keys(next)) rec.spend[k] = Math.max(0, (rec.spend[k] || 0) - (prev[k] || 0) + next[k]);
147
+ if (r) r.spent = next; else rec.runs.push({ runId: p.runId, jobId: null, at, spent: next });
148
+ break;
149
+ }
150
+ case 'project.decision':
151
+ rec.decisions.push({ at, by: p.by || 'person', kind: p.kind || 'note', text: String(p.text || '').slice(0, 2000), refs: Array.isArray(p.refs) ? p.refs.slice(0, 8) : [] });
152
+ break;
153
+ case 'project.report':
154
+ rec.report = { text: String(p.text || ''), by: p.by || 'runner', at };
155
+ break;
156
+ default: break;
157
+ }
158
+ return rec;
159
+ }
160
+
161
+ /** How far along: jobs by status, spend against the budget, whether done-when is claimed. */
162
+ export function projectProgress(rec) {
163
+ const by = {};
164
+ for (const j of rec?.jobs || []) by[j.status] = (by[j.status] || 0) + 1;
165
+ const cap = rec?.page?.budget || {};
166
+ const spend = rec?.spend || {};
167
+ const pct = cap.tokens ? Math.min(1, (spend.tokens || 0) / cap.tokens) : cap.usd ? Math.min(1, (spend.usd || 0) / cap.usd) : cap.ms ? Math.min(1, (spend.ms || 0) / cap.ms) : null;
168
+ const total = (rec?.jobs || []).length;
169
+ const done = by.done || 0;
170
+ return { jobs: { total, by }, done, open: (by.open || 0) + (by.evaluating || 0) + (by.recruited || 0) + (by['in-progress'] || 0), spend, cap, budgetUsed: pct, hasReport: !!rec?.report, status: rec?.status || 'draft' };
171
+ }
@@ -82,7 +82,7 @@ export class ScorecardStore {
82
82
  if (!p.agentId) return null;
83
83
  return this.append({
84
84
  agentId: p.agentId, kind: p.outcome === 'task.failed' ? 'task.failed' : 'task.done', at: ev.at,
85
- runId: run?.id || p.runId, taskId: p.taskId, model: p.model, size: p.size, roleKind: p.roleKind,
85
+ runId: run?.id || p.runId, taskId: p.taskId, model: p.model, engine: p.engine, scm: p.scm, size: p.size, roleKind: p.roleKind,
86
86
  tools: p.tools, with: p.with, refs: p.refs, error: p.error,
87
87
  ...(run?.projectId ? { projectId: run.projectId } : {}), ...(run?.jobId ? { jobId: run.jobId } : {}),
88
88
  }).catch(() => null);