@chatpanel/events 0.85.0 → 0.89.0

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,228 @@
1
+ // The MODEL LEDGER — an engine's record, the scorecard pattern applied to engines
2
+ // (architecture-pillars.md §13.2).
3
+ //
4
+ // One chained, store-attested ledger per ENGINE — keyed like the scorecard's `byEngine`
5
+ // (`model:<provider>/<model>`, `harness:<id>`), because the same model at two providers is
6
+ // two records: availability, cost and latency are the provider's, not the model's. Entries
7
+ // are FACTS the runner and the gateway observe, never claims:
8
+ //
9
+ // call one turn: time to first token, total, tokens in/out, cost when priced, was
10
+ // the JSON valid, were the tool calls valid, did it come back empty / refused
11
+ // / truncated, did it succeed
12
+ // declined it did not answer, and why (unavailable · auth · rate · credits · timeout ·
13
+ // context) — the availability signal
14
+ // rotated-from a task left it for another engine mid-run
15
+ // rating a task's verdict, attributed to the engine that served it (and to the agent)
16
+ // capability a proof: it was asked for X and it did / did not deliver
17
+ // price what a token costs here — from the provider's list or typed by a person
18
+ //
19
+ // `summarizeEngine(entries)` → the ENGINE CARD: availability, reliability, latency, cost,
20
+ // capability proofs (a capability with three failed proofs is WITHDRAWN until a person
21
+ // re-enables it), quality by job kind, the last refs. model-candidates.js `applyCard` hands
22
+ // the card to `applyOverride`: observed quality / latency / cost replace the name-based
23
+ // guess wherever there is enough history (≥ `minCalls`), the guess stays as the prior until
24
+ // then, and the result says which it used (`observed[]`). Reach is never learned, only
25
+ // typed — a ledger cannot move a model closer than the URL says.
26
+ //
27
+ // Hashing, attestation and chain verification are scorecard.js's, unchanged: the same store
28
+ // marks both, the same `verifyChain` checks both.
29
+
30
+ import { canonical, sha256, engineKey, normalizeEngine } from './scorecard.js';
31
+ export { verifyChain, attest, verifyAttested } from './scorecard.js';
32
+
33
+ export const LEDGER_VERSION = 1;
34
+ export const LEDGER_ENTRY_KINDS = Object.freeze(['call', 'declined', 'rotated-from', 'rating', 'capability', 'price']);
35
+ export const DECLINE_REASONS = Object.freeze(['unavailable', 'auth', 'rate', 'credits', 'timeout', 'context', 'other']);
36
+ export const STRUCTURED = Object.freeze(['ok', 'bad', 'n/a']);
37
+ /** Failed proofs before a capability leaves the card. */
38
+ export const WITHDRAW_AFTER = 3;
39
+ /** Calls before an observed number outranks the name-based guess. Small; configurable. */
40
+ export const DEFAULT_MIN_CALLS = 5;
41
+
42
+ const n0 = (v) => Math.max(0, Math.round(Number(v) || 0));
43
+ const money = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? undefined : Math.max(0, Number(v)));
44
+ const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
45
+ const bool = (v) => v === true;
46
+ const str = (v, n = 120) => (v == null || v === '' ? undefined : String(v).slice(0, n));
47
+ const strip = (o) => { for (const k of Object.keys(o)) if (o[k] === undefined) delete o[k]; return o; };
48
+
49
+ /** The ledger's key for an engine — the scorecard's `engineKey`, so the two join. */
50
+ export function ledgerKey(engine) { return engineKey(engine); }
51
+
52
+ /** A call fact, normalized. Rates are computed later; here every field is a plain observation. */
53
+ export function normalizeCall(c) {
54
+ const src = c && typeof c === 'object' ? c : {};
55
+ return strip({
56
+ ok: src.ok !== false,
57
+ ttftMs: src.ttftMs != null ? n0(src.ttftMs) : undefined,
58
+ totalMs: src.totalMs != null ? n0(src.totalMs) : undefined,
59
+ tokensIn: src.tokensIn != null ? n0(src.tokensIn) : undefined,
60
+ tokensOut: src.tokensOut != null ? n0(src.tokensOut) : undefined,
61
+ // The total when the split is unknown (a harness reports one number, or none).
62
+ tokens: src.tokens != null && src.tokensIn == null && src.tokensOut == null ? n0(src.tokens) : undefined,
63
+ cost: money(src.cost),
64
+ structured: STRUCTURED.includes(src.structured) ? src.structured : 'n/a',
65
+ toolCalls: src.toolCalls && typeof src.toolCalls === 'object' ? { asked: n0(src.toolCalls.asked), valid: Math.min(n0(src.toolCalls.asked), n0(src.toolCalls.valid)) } : undefined,
66
+ empty: bool(src.empty) || undefined,
67
+ refused: bool(src.refused) || undefined,
68
+ truncated: bool(src.truncated) || undefined,
69
+ });
70
+ }
71
+
72
+ /**
73
+ * A new entry chained onto `prev`. `fact.engine` is required and keyed; the rest is by kind.
74
+ * Pure apart from the digest; the store attests.
75
+ */
76
+ export async function makeLedgerEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
77
+ if (!fact || typeof fact !== 'object') throw new Error('model-ledger: an entry needs a fact');
78
+ if (!LEDGER_ENTRY_KINDS.includes(fact.kind)) throw new Error(`model-ledger: kind must be one of ${LEDGER_ENTRY_KINDS.join(', ')}`);
79
+ const engine = normalizeEngine(fact.engine);
80
+ if (!engine) throw new Error('model-ledger: engine required');
81
+ const key = engineKey(engine);
82
+ if (prev && prev.key !== key) throw new Error(`model-ledger: entry for ${key} chained onto ${prev.key}`);
83
+ const e = strip({
84
+ v: LEDGER_VERSION,
85
+ seq: prev ? prev.seq + 1 : 0,
86
+ key,
87
+ engine,
88
+ kind: fact.kind,
89
+ at: Number(fact.at) || now(),
90
+ runId: str(fact.runId), taskId: str(fact.taskId), agentId: str(fact.agentId), jobKind: str(fact.jobKind, 60),
91
+ call: fact.kind === 'call' ? normalizeCall(fact.call) : undefined,
92
+ 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,
93
+ rotated: fact.kind === 'rotated-from' ? strip({ to: engineKey(fact.rotated?.to) || undefined, reason: str(fact.rotated?.reason, 300) }) : undefined,
94
+ 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,
95
+ capability: fact.kind === 'capability' ? { id: String(fact.capability?.id || '').slice(0, 40), proved: bool(fact.capability?.proved) } : undefined,
96
+ 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,
97
+ refs: Array.isArray(fact.refs) && fact.refs.length ? fact.refs.map(String).slice(0, 12) : undefined,
98
+ prev: prev ? prev.hash : null,
99
+ });
100
+ if (e.kind === 'capability' && !e.capability.id) throw new Error('model-ledger: capability.id required');
101
+ const { hash: _h, sig: _s, ...hashable } = e;
102
+ e.hash = await sha256(canonical(hashable), { subtle });
103
+ return e;
104
+ }
105
+
106
+ const percentile = (xs, p) => {
107
+ if (!xs.length) return null;
108
+ const s = [...xs].sort((a, b) => a - b);
109
+ return s[Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))];
110
+ };
111
+ const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
112
+ const rate = (n, of) => (of ? Math.round((n / of) * 1000) / 1000 : null);
113
+ const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
114
+
115
+ /**
116
+ * The engine card. `minCalls` marks it `observed` once there is enough history; `now`
117
+ * bounds the by-hour availability band (the last 24 h) and the "declining right now" check.
118
+ */
119
+ export function summarizeEngine(entries, { minCalls = DEFAULT_MIN_CALLS, now = Date.now(), recent = 5 } = {}) {
120
+ const list = (entries || []).filter((e) => e && e.kind);
121
+ const calls = list.filter((e) => e.kind === 'call');
122
+ const declines = list.filter((e) => e.kind === 'declined');
123
+ const attempts = calls.length + declines.length;
124
+ // Availability: declines over attempts, and the last 24 hours in bands of one.
125
+ const byHour = Array.from({ length: 24 }, () => ({ calls: 0, declines: 0 }));
126
+ for (const e of [...calls, ...declines]) {
127
+ const h = Math.floor((now - e.at) / 3600000);
128
+ if (h >= 0 && h < 24) byHour[23 - h][e.kind === 'call' ? 'calls' : 'declines'] += 1;
129
+ }
130
+ const declinesBy = {};
131
+ for (const e of declines) declinesBy[e.declined.reason] = (declinesBy[e.declined.reason] || 0) + 1;
132
+ // Declining right now: the last three attempts all declined, within the last hour.
133
+ const lastThree = [...calls, ...declines].sort((a, b) => a.at - b.at).slice(-3);
134
+ const decliningNow = lastThree.length === 3 && lastThree.every((e) => e.kind === 'declined' && now - e.at < 3600000);
135
+ // Reliability: each a rate over the calls it applies to.
136
+ const withJson = calls.filter((e) => e.call.structured !== 'n/a');
137
+ const withTools = calls.filter((e) => e.call.toolCalls?.asked);
138
+ const reliability = {
139
+ failRate: rate(calls.filter((e) => !e.call.ok).length, calls.length),
140
+ empty: rate(calls.filter((e) => e.call.empty).length, calls.length),
141
+ refused: rate(calls.filter((e) => e.call.refused).length, calls.length),
142
+ truncated: rate(calls.filter((e) => e.call.truncated).length, calls.length),
143
+ badJson: rate(withJson.filter((e) => e.call.structured === 'bad').length, withJson.length),
144
+ 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)),
145
+ };
146
+ // Latency.
147
+ const ttft = calls.map((e) => e.call.ttftMs).filter((v) => v != null);
148
+ const total = calls.map((e) => e.call.totalMs).filter((v) => v != null);
149
+ 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 } };
150
+ // Cost: the latest price entry prices every call that reported tokens; a call that
151
+ // reported its own cost is taken as is; otherwise the mean tokens per call stands in.
152
+ const price = list.filter((e) => e.kind === 'price').at(-1)?.price || null;
153
+ 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);
154
+ const tokens = calls.map((e) => (e.call.tokensIn || 0) + (e.call.tokensOut || 0) + (e.call.tokens || 0)).filter((v) => v > 0);
155
+ 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 } : {}) };
156
+ // Capability proofs: asked vs proved; withdrawn after WITHDRAW_AFTER failures unless a
157
+ // later proof succeeded (a person re-enabling it is a proof they record).
158
+ const proofs = {};
159
+ for (const e of list.filter((x) => x.kind === 'capability')) {
160
+ const p = proofs[e.capability.id] || (proofs[e.capability.id] = { asked: 0, proved: 0, failedSince: 0 });
161
+ p.asked += 1;
162
+ if (e.capability.proved) { p.proved += 1; p.failedSince = 0; } else p.failedSince += 1;
163
+ }
164
+ const capabilities = {
165
+ proved: Object.keys(proofs).filter((id) => proofs[id].proved > 0 && proofs[id].failedSince < WITHDRAW_AFTER).sort(),
166
+ withdrawn: Object.keys(proofs).filter((id) => proofs[id].failedSince >= WITHDRAW_AFTER).sort(),
167
+ proofs: Object.fromEntries(Object.entries(proofs).map(([id, p]) => [id, { asked: p.asked, proved: p.proved }])),
168
+ };
169
+ // Quality: mean rating, overall and by job kind.
170
+ const ratings = list.filter((e) => e.kind === 'rating');
171
+ const byJobKind = {};
172
+ for (const e of ratings) { const k = e.rating.jobKind || 'any'; (byJobKind[k] = byJobKind[k] || []).push(e.rating.score); }
173
+ const quality = {
174
+ overall: { avg: r3(mean(ratings.map((e) => e.rating.score))), count: ratings.length },
175
+ byJobKind: Object.fromEntries(Object.entries(byJobKind).map(([k, xs]) => [k, { avg: r3(mean(xs)), count: xs.length }])),
176
+ };
177
+ const rotatedFrom = list.filter((e) => e.kind === 'rotated-from').length;
178
+ return {
179
+ key: list[0]?.key || null,
180
+ engine: list[0]?.engine || null,
181
+ entries: list.length,
182
+ calls: calls.length,
183
+ declines: declines.length,
184
+ observed: calls.length >= minCalls,
185
+ availability: { rate: attempts ? r3(1 - declines.length / attempts) : null, attempts, declinesBy, byHour, decliningNow },
186
+ reliability,
187
+ latency,
188
+ cost,
189
+ capabilities,
190
+ quality,
191
+ rotatedFrom,
192
+ refs: [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent),
193
+ since: list[0]?.at || null,
194
+ last: list.at(-1)?.at || null,
195
+ head: list.at(-1)?.hash || null,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * The override a card yields for model-candidates.js `applyOverride` — only the fields it
201
+ * has enough history for. `quality` is the mean rating (for `jobKind` when the card has
202
+ * ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
203
+ * ttft was recorded); `costPer1k` from the price when one is known; `available: false`
204
+ * only while it is declining right now. Returns `{ override, observed }`.
205
+ *
206
+ * Lives here, beside the card it reads, so recruit.js and a store without a router can use
207
+ * it; `applyCard` (the override over the guess) stays in model-candidates.js beside
208
+ * `applyOverride`, the seam it feeds.
209
+ */
210
+ export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
211
+ const override = {}; const observed = [];
212
+ if (!card) return { override, observed };
213
+ const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
214
+ if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
215
+ const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
216
+ if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
217
+ // Six places, not three: a per-1k price is often 0.0004, and rounding it to 0 made a paid model read as free.
218
+ 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'); }
219
+ if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
220
+ return { override, observed };
221
+ }
222
+
223
+ // `applyCard` — the card over the name-based guess — lives in model-candidates.js beside
224
+ // `applyOverride`, the seam it feeds; this module stays importable by a store that has no
225
+ // router (the gateway vendors it with scorecard.js only).
226
+ // Agent scores normalised by engine (§13.3) live beside the card they adjust: scorecard.js
227
+ // `adjustSummary` and `fit(job, type, summary, { qualityOf })`.
228
+ export { adjustSummary } from './scorecard.js';
package/model-picker.js CHANGED
@@ -102,7 +102,9 @@ export function groupModels(models, { selectedId = '' } = {}) {
102
102
  if (agents.length) {
103
103
  sections.push({
104
104
  key: 'agents',
105
- label: 'Agents',
105
+ // Naming phase 1 (naming-revamp.md): the CLI coding agents are HARNESSES — a runtime one
106
+ // of the user's own agents can be given as its engine. The key and kind are code.
107
+ label: 'Harnesses',
106
108
  kind: 'agent',
107
109
  items: agents.slice().sort(availableFirst).map(decorate),
108
110
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.85.0",
3
+ "version": "0.89.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,6 +8,7 @@
8
8
  ".": "./index.js",
9
9
  "./adapters.js": "./adapters.js",
10
10
  "./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
11
+ "./agent.js": "./agent.js",
11
12
  "./attribution.js": "./attribution.js",
12
13
  "./backup-envelope.js": "./backup-envelope.js",
13
14
  "./budget.js": "./budget.js",
@@ -21,6 +22,7 @@
21
22
  "./curate.js": "./curate.js",
22
23
  "./distance.js": "./distance.js",
23
24
  "./entitlement.js": "./entitlement.js",
25
+ "./engine.js": "./engine.js",
24
26
  "./entity.js": "./entity.js",
25
27
  "./event.js": "./event.js",
26
28
  "./extraction.js": "./extraction.js",
@@ -47,6 +49,7 @@
47
49
  "./meeting-text.js": "./meeting-text.js",
48
50
  "./memory.js": "./memory.js",
49
51
  "./model-candidates.js": "./model-candidates.js",
52
+ "./model-ledger.js": "./model-ledger.js",
50
53
  "./model-picker.js": "./model-picker.js",
51
54
  "./note-actions.js": "./note-actions.js",
52
55
  "./note-graph.js": "./note-graph.js",
@@ -63,6 +66,7 @@
63
66
  "./reach.js": "./reach.js",
64
67
  "./recipe-tool.js": "./recipe-tool.js",
65
68
  "./recipe.js": "./recipe.js",
69
+ "./recruit.js": "./recruit.js",
66
70
  "./record-list.js": "./record-list.js",
67
71
  "./redaction-tokens.js": "./redaction-tokens.js",
68
72
  "./ref.js": "./ref.js",
@@ -73,6 +77,7 @@
73
77
  "./rrf.js": "./rrf.js",
74
78
  "./rules.js": "./rules.js",
75
79
  "./schedule.js": "./schedule.js",
80
+ "./scm-connection.js": "./scm-connection.js",
76
81
  "./scopes.js": "./scopes.js",
77
82
  "./search-engines.js": "./search-engines.js",
78
83
  "./skill-manifest.js": "./skill-manifest.js",
@@ -95,6 +100,9 @@
95
100
  "./team-task.js": "./team-task.js",
96
101
  "./team-record.js": "./team-record.js",
97
102
  "./scorecard.js": "./scorecard.js",
103
+ "./project.js": "./project.js",
104
+ "./job.js": "./job.js",
105
+ "./gate.js": "./gate.js",
98
106
  "./team-plan.js": "./team-plan.js",
99
107
  "./team-run.js": "./team-run.js",
100
108
  "./team-tool.js": "./team-tool.js",
@@ -130,6 +138,8 @@
130
138
  "README.md",
131
139
  "adapters.js",
132
140
  "adaptive-tool-policy.js",
141
+ "agent.js",
142
+ "attribution.js",
133
143
  "backup-envelope.js",
134
144
  "budget.js",
135
145
  "capability.js",
@@ -142,6 +152,7 @@
142
152
  "curate.js",
143
153
  "distance.js",
144
154
  "entitlement.js",
155
+ "engine.js",
145
156
  "entity.js",
146
157
  "event.js",
147
158
  "extraction.js",
@@ -168,6 +179,8 @@
168
179
  "meeting-shape.js",
169
180
  "meeting-text.js",
170
181
  "memory.js",
182
+ "model-candidates.js",
183
+ "model-ledger.js",
171
184
  "model-picker.js",
172
185
  "note-actions.js",
173
186
  "note-graph.js",
@@ -184,15 +197,18 @@
184
197
  "reach.js",
185
198
  "recipe-tool.js",
186
199
  "recipe.js",
200
+ "recruit.js",
187
201
  "record-list.js",
188
202
  "redaction-tokens.js",
189
203
  "ref.js",
190
204
  "registry.js",
191
205
  "route-graph.js",
206
+ "route-strategies.js",
192
207
  "router.js",
193
208
  "rrf.js",
194
209
  "rules.js",
195
210
  "schedule.js",
211
+ "scm-connection.js",
196
212
  "scopes.js",
197
213
  "search-engines.js",
198
214
  "skill-manifest.js",
@@ -215,6 +231,9 @@
215
231
  "team-task.js",
216
232
  "team-record.js",
217
233
  "scorecard.js",
234
+ "project.js",
235
+ "job.js",
236
+ "gate.js",
218
237
  "team-plan.js",
219
238
  "team-run.js",
220
239
  "team-tool.js",
@@ -238,6 +257,7 @@
238
257
  "vault.js",
239
258
  "view.js",
240
259
  "voice-intents.js",
260
+ "voice-speaker.js",
241
261
  "weather-tool.js",
242
262
  "weather.js",
243
263
  "web-search-tool.js",
package/project.js ADDED
@@ -0,0 +1,170 @@
1
+ // A project — the page a goal starts on, and the record that folds everything done for it.
2
+ //
3
+ // Every job or goal starts here (F8 §12): the goal, defined by its stakeholder — the *chief
4
+ // executive*: a person by default, an agent from the pool when the person delegates it —
5
+ // with a done-when a run can be checked against, a budget the jobs are carved from, the
6
+ // repos the work happens in, and the gate that says how far a team may go without a person.
7
+ // A project is data (a `projects` prefs section, both clients) and a gateway record that
8
+ // folds its jobs, runs and spend from events, the way a run folds (team-record.js).
9
+ //
10
+ // A project's status is a small machine: draft (a goal being written) → open (jobs may be
11
+ // posted) → active (a job was recruited) → done (done-when held, a person closed it) or
12
+ // closed (abandoned). Nothing here runs anything: project-run.js is the executive loop.
13
+
14
+ import { validateBudget, normalizeBudget } from './budget.js';
15
+ import { normalizeGate, validateGate } from './gate.js';
16
+
17
+ export const PROJECT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
18
+ export const PROJECT_STATUSES = Object.freeze(['draft', 'open', 'active', 'done', 'closed']);
19
+ const NEXT = Object.freeze({ draft: ['open', 'closed'], open: ['active', 'closed', 'draft'], active: ['done', 'closed', 'open'], done: ['closed', 'open'], closed: ['open'] });
20
+ export const MAX_REPOS = 16;
21
+
22
+ export class ProjectError extends Error {
23
+ constructor(code, message) { super(message); this.name = 'ProjectError'; this.code = code; }
24
+ }
25
+
26
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
27
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
28
+
29
+ export function validateProject(p) {
30
+ const errors = [];
31
+ if (!isRecord(p)) return { ok: false, errors: ['project must be an object'] };
32
+ if (!PROJECT_ID_RE.test(String(p.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
33
+ if (!clip(p.title, 200)) errors.push('title: what the project is called');
34
+ if (!clip(p.goal, 4000)) errors.push('goal: what done looks like, in the stakeholder\'s words');
35
+ if (p.doneWhen !== undefined && !clip(p.doneWhen, 2000)) errors.push('doneWhen: a check a run can be held to, or leave it out');
36
+ 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');
37
+ if (p.status !== undefined && !PROJECT_STATUSES.includes(p.status)) errors.push(`status: one of ${PROJECT_STATUSES.join(', ')}`);
38
+ const b = validateBudget(p.budget);
39
+ if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`));
40
+ if (p.repos !== undefined) {
41
+ if (!Array.isArray(p.repos)) errors.push('repos: a list of repo ids');
42
+ else if (p.repos.length > MAX_REPOS) errors.push(`repos: at most ${MAX_REPOS}`);
43
+ }
44
+ if (p.gate !== undefined && p.gate !== null) errors.push(...validateGate(p.gate, { partial: true }).errors.map((e) => `gate: ${e}`));
45
+ return { ok: errors.length === 0, errors };
46
+ }
47
+
48
+ /** The stored form: defaults filled, the budget normalised, the gate (if any) normalised. */
49
+ export function normalizeProject(p) {
50
+ const v = validateProject(p);
51
+ if (!v.ok) throw new ProjectError('INVALID', v.errors.join('; '));
52
+ return {
53
+ id: String(p.id),
54
+ title: clip(p.title, 200),
55
+ goal: clip(p.goal, 4000),
56
+ doneWhen: clip(p.doneWhen, 2000),
57
+ stakeholder: p.stakeholder ? String(p.stakeholder) : 'person',
58
+ budget: normalizeBudget(p.budget),
59
+ status: PROJECT_STATUSES.includes(p.status) ? p.status : 'draft',
60
+ repos: Array.isArray(p.repos) ? [...new Set(p.repos.map((r) => clip(r, 120)).filter(Boolean))].slice(0, MAX_REPOS) : [],
61
+ ...(p.gate ? { gate: normalizeGate(p.gate, { partial: true }) } : {}),
62
+ tags: Array.isArray(p.tags) ? [...new Set(p.tags.map((t) => clip(t, 40)).filter(Boolean))].slice(0, 12) : [],
63
+ createdBy: clip(p.createdBy, 80) || 'person',
64
+ createdAt: Number(p.createdAt) || Date.now(),
65
+ ...(p.updatedAt ? { updatedAt: Number(p.updatedAt) } : {}),
66
+ };
67
+ }
68
+
69
+ export function defineProject(p) { return Object.freeze(normalizeProject(p)); }
70
+
71
+ /** May the project move from `from` to `to`? The machine above; a person's close is always allowed. */
72
+ export function canTransition(from, to) {
73
+ return (NEXT[from] || []).includes(to);
74
+ }
75
+
76
+ /** A blank page for the form. */
77
+ export function blankProject() {
78
+ return { id: '', title: '', goal: '', doneWhen: '', stakeholder: 'person', budget: { tokens: 200000, ms: 3600000 }, status: 'draft', repos: [], tags: [] };
79
+ }
80
+
81
+ /** The form → a project, or the errors (the same shaping both clients use). */
82
+ export function projectFromForm(form) {
83
+ const budget = {};
84
+ for (const k of ['tokens', 'calls', 'ms', 'usd']) {
85
+ const v = Number(form?.budget?.[k]);
86
+ if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
87
+ }
88
+ const p = {
89
+ id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
90
+ title: form?.title, goal: form?.goal, doneWhen: form?.doneWhen, stakeholder: form?.stakeholder || 'person',
91
+ budget, status: form?.status || 'draft',
92
+ repos: String(Array.isArray(form?.repos) ? form.repos.join(',') : form?.repos || '').split(/[,\s]+/).filter(Boolean),
93
+ tags: String(Array.isArray(form?.tags) ? form.tags.join(',') : form?.tags || '').split(/[,\s]+/).filter(Boolean),
94
+ ...(form?.gate ? { gate: form.gate } : {}), ...(form?.createdAt ? { createdAt: form.createdAt } : {}),
95
+ };
96
+ const v = validateProject(p);
97
+ return v.ok ? { ok: true, project: normalizeProject(p) } : { ok: false, errors: v.errors };
98
+ }
99
+
100
+ // ── the record: a project folded from its events ────────────────────────────────────────
101
+
102
+ /** The empty record — what the gateway holds per project and both clients read. */
103
+ export function emptyProjectRecord({ id, now = Date.now() } = {}) {
104
+ return { id, page: null, status: 'draft', jobs: [], runs: [], spend: { tokens: 0, calls: 0, usd: 0, ms: 0 }, decisions: [], report: null, createdAt: now, lastEventAt: now };
105
+ }
106
+
107
+ /**
108
+ * Fold one event into the record. Events: `project.created` `{ project }` · `project.updated`
109
+ * `{ project }` · `project.status` `{ status, by, note }` · `job.posted` `{ job }` ·
110
+ * `job.updated` `{ job }` (any field: applications, recruited, status) · `run.linked`
111
+ * `{ runId, jobId }` · `run.spent` `{ runId, spent }` · `project.decision` `{ by, kind, text,
112
+ * refs }` · `project.report` `{ text, by }`. Idempotent by job id and run id.
113
+ */
114
+ export function foldProject(rec, ev) {
115
+ const type = String(ev?.type || '');
116
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
117
+ const at = Number(ev?.at) || Date.now();
118
+ rec.lastEventAt = at;
119
+ switch (type) {
120
+ case 'project.created':
121
+ case 'project.updated':
122
+ if (p.project && typeof p.project === 'object') { rec.page = { ...p.project, updatedAt: at }; rec.status = p.project.status || rec.status; }
123
+ break;
124
+ case 'project.status':
125
+ 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}` : ''}` }); }
126
+ break;
127
+ case 'job.posted':
128
+ if (p.job?.id && !rec.jobs.some((j) => j.id === p.job.id)) rec.jobs.push({ ...p.job, postedAt: at });
129
+ if (rec.status === 'draft') rec.status = 'open';
130
+ break;
131
+ case 'job.updated': {
132
+ const i = rec.jobs.findIndex((j) => j.id === p.job?.id);
133
+ if (i >= 0) rec.jobs[i] = { ...rec.jobs[i], ...p.job, updatedAt: at };
134
+ if (p.job?.status === 'recruited' || p.job?.status === 'in-progress') { if (rec.status === 'open' || rec.status === 'draft') rec.status = 'active'; }
135
+ break;
136
+ }
137
+ case 'run.linked':
138
+ if (p.runId && !rec.runs.some((r) => r.runId === p.runId)) rec.runs.push({ runId: p.runId, jobId: p.jobId || null, at });
139
+ break;
140
+ case 'run.spent': {
141
+ const r = rec.runs.find((x) => x.runId === p.runId);
142
+ const prev = r?.spent || { tokens: 0, calls: 0, usd: 0, ms: 0 };
143
+ 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 };
144
+ // A run reports its running total; the project's spend is the sum of every run's latest.
145
+ for (const k of Object.keys(next)) rec.spend[k] = Math.max(0, (rec.spend[k] || 0) - (prev[k] || 0) + next[k]);
146
+ if (r) r.spent = next; else rec.runs.push({ runId: p.runId, jobId: null, at, spent: next });
147
+ break;
148
+ }
149
+ case 'project.decision':
150
+ 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) : [] });
151
+ break;
152
+ case 'project.report':
153
+ rec.report = { text: String(p.text || ''), by: p.by || 'runner', at };
154
+ break;
155
+ default: break;
156
+ }
157
+ return rec;
158
+ }
159
+
160
+ /** How far along: jobs by status, spend against the budget, whether done-when is claimed. */
161
+ export function projectProgress(rec) {
162
+ const by = {};
163
+ for (const j of rec?.jobs || []) by[j.status] = (by[j.status] || 0) + 1;
164
+ const cap = rec?.page?.budget || {};
165
+ const spend = rec?.spend || {};
166
+ 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;
167
+ const total = (rec?.jobs || []).length;
168
+ const done = by.done || 0;
169
+ 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' };
170
+ }