@chatpanel/gateway 0.6.85 → 0.6.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.85",
3
+ "version": "0.6.87",
4
4
  "description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,92 @@
1
+ // The agents' scorecards — every fact the runner said about a member, chained and attested
2
+ // here, where no client and no agent can write one for itself.
3
+ //
4
+ // One chain per agent id. A fact arrives as the run store's `task.scored` event (or a
5
+ // person's rating through the route); the store makes the entry (scorecard.js: canonical,
6
+ // hashed onto the previous), marks it with an HMAC over a key only this process holds, and
7
+ // appends. Nothing is ever edited: a correction is a new entry. The file is encrypted at
8
+ // rest with the team store's key, like the runs; the attestation key is derived from it
9
+ // (HKDF-style label), so the same install attests the same way across restarts and a copied
10
+ // file elsewhere cannot forge a mark.
11
+ //
12
+ // Read: `GET /v1/agents/:id/scorecard` → the chain, its summary, and whether it verifies.
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import os from 'node:os';
17
+ import { createHmac, webcrypto } from 'node:crypto';
18
+ import { makeEntry, attest, verifyChain, verifyAttested, summarize, SCORECARD_ENTRY_KINDS } from './scorecard.js';
19
+
20
+ const DIR = join(os.homedir(), '.chatpanel');
21
+ const STORE_PATH = process.env.CHATPANEL_SCORECARDS_STORE || join(DIR, 'scorecards.json');
22
+ const MAX_ENTRIES_PER_AGENT = 5000;
23
+
24
+ export class ScorecardStore {
25
+ constructor({ storePath = STORE_PATH, key = null, now = () => Date.now() } = {}) {
26
+ this.path = storePath;
27
+ this.now = now;
28
+ // The attestation key: derived from the store key with a label, never the key itself.
29
+ this._mark = key ? createHmac('sha256', key).update('chatpanel:scorecard:attest:v1').digest() : null;
30
+ this.chains = new Map(); // agentId -> [entries]
31
+ this._queue = Promise.resolve(); // appends are serialised: a chain has one head
32
+ }
33
+ load() {
34
+ try {
35
+ if (existsSync(this.path)) {
36
+ const doc = JSON.parse(readFileSync(this.path, 'utf8'));
37
+ for (const [id, entries] of Object.entries(doc?.chains || {})) if (Array.isArray(entries)) this.chains.set(id, entries);
38
+ }
39
+ } catch { this.chains = new Map(); }
40
+ return this;
41
+ }
42
+ save() {
43
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
44
+ const tmp = `${this.path}.${process.pid}.tmp`;
45
+ writeFileSync(tmp, JSON.stringify({ v: 1, chains: Object.fromEntries(this.chains) }), { mode: 0o600 });
46
+ renameSync(tmp, this.path);
47
+ }
48
+ /** Append one fact to an agent's chain: made, chained, attested, saved. Serialised per store. */
49
+ append(fact) {
50
+ const run = async () => {
51
+ const agentId = String(fact?.agentId || '');
52
+ if (!agentId) throw new Error('scorecard: agentId required');
53
+ if (!SCORECARD_ENTRY_KINDS.includes(fact?.kind)) throw new Error(`scorecard: kind must be one of ${SCORECARD_ENTRY_KINDS.join(', ')}`);
54
+ const chain = this.chains.get(agentId) || [];
55
+ if (chain.length >= MAX_ENTRIES_PER_AGENT) throw new Error('scorecard: chain is full');
56
+ let entry = await makeEntry({ ...fact, at: fact.at || this.now() }, chain.at(-1) || null, { now: this.now, subtle: webcrypto.subtle });
57
+ if (this._mark) entry = await attest(entry, this._mark, { subtle: webcrypto.subtle });
58
+ chain.push(entry);
59
+ this.chains.set(agentId, chain);
60
+ this.save();
61
+ return entry;
62
+ };
63
+ const p = this._queue.then(run, run);
64
+ this._queue = p.catch(() => {});
65
+ return p;
66
+ }
67
+ /** The chain, its card, and whether it verifies — what a recruiter (or a person) reads. */
68
+ async get(agentId) {
69
+ const chain = this.chains.get(String(agentId || '')) || [];
70
+ const verified = await verifyChain(chain, { subtle: webcrypto.subtle });
71
+ const attested = this._mark ? await verifyAttested(chain, this._mark, { subtle: webcrypto.subtle }) : { ok: false, attested: 0, of: chain.length };
72
+ return { agentId: String(agentId || ''), entries: chain, summary: summarize(chain), verified, attested };
73
+ }
74
+ /** Every agent's card, without the chains. */
75
+ list() {
76
+ return [...this.chains.entries()].map(([agentId, chain]) => ({ agentId, ...summarize(chain) }));
77
+ }
78
+ /** A run store event, as the fold sees it: only `task.scored` becomes a fact. */
79
+ fromRunEvent(ev, run) {
80
+ if (String(ev?.type || '') !== 'task.scored') return null;
81
+ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
82
+ if (!p.agentId) return null;
83
+ return this.append({
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,
86
+ tools: p.tools, with: p.with, refs: p.refs, error: p.error,
87
+ ...(run?.projectId ? { projectId: run.projectId } : {}), ...(run?.jobId ? { jobId: run.jobId } : {}),
88
+ }).catch(() => null);
89
+ }
90
+ }
91
+
92
+ export function createScorecardStore(opts) { return new ScorecardStore(opts).load(); }
@@ -0,0 +1,192 @@
1
+ // VENDORED from @chatpanel/events/scorecard.js — edit there, then copy over.
2
+ // An agent's scorecard — an immutable record of what it actually did, and matching on it.
3
+ //
4
+ // A scorecard is a CHAIN of entries, one per fact, append-only: a task it finished (how big,
5
+ // with which tools, alongside whom, in which role), a rating a job gave it, an agent it
6
+ // created, an interaction. Every entry carries the hash of the one before and its own hash,
7
+ // so an edit anywhere breaks every link after it; the gateway's store adds its own mark
8
+ // (an HMAC over the hash with a key only the store holds) so an entry a client — or an
9
+ // agent — wrote for itself shows as unattested. The facts are produced by the runner and
10
+ // attested by the store, never written by the agent: the only way to a better scorecard is
11
+ // the work.
12
+ //
13
+ // `summarize` turns the chain into the card a recruiter reads; `fit` scores an agent type
14
+ // against a job's needs with that record — the same function the evaluator starts from, so
15
+ // an application's fit has reasons a person can read and overrule.
16
+ //
17
+ // Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
18
+
19
+ export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
20
+ export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
21
+ export const SCORECARD_VERSION = 1;
22
+
23
+ const enc = new TextEncoder();
24
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
25
+
26
+ /** Canonical JSON: keys sorted at every level, so the same fact hashes the same everywhere. */
27
+ export function canonical(v) {
28
+ if (v === null || typeof v !== 'object') return JSON.stringify(v);
29
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
30
+ return `{${Object.keys(v).sort().map((k) => (v[k] === undefined ? null : `${JSON.stringify(k)}:${canonical(v[k])}`)).filter(Boolean).join(',')}}`;
31
+ }
32
+
33
+ /** SHA-256 over text, as hex; `subtle` is injectable (a runtime without it passes its own). */
34
+ export async function sha256(text, { subtle = globalThis.crypto?.subtle } = {}) {
35
+ if (!subtle) throw new Error('scorecard: no crypto.subtle — pass one');
36
+ return hex(await subtle.digest('SHA-256', enc.encode(String(text))));
37
+ }
38
+
39
+ /** The fields a hash covers — everything but the hash and the store's mark. */
40
+ function hashable(e) {
41
+ const { hash: _h, sig: _s, ...rest } = e;
42
+ return rest;
43
+ }
44
+
45
+ /**
46
+ * A new entry chained onto `prev` (the last entry, or null for the first). Pure apart from
47
+ * the digest: the caller (the store) decides whether it is attested.
48
+ */
49
+ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
50
+ if (!fact || typeof fact !== 'object') throw new Error('scorecard: an entry needs a fact');
51
+ if (!SCORECARD_ENTRY_KINDS.includes(fact.kind)) throw new Error(`scorecard: kind must be one of ${SCORECARD_ENTRY_KINDS.join(', ')}`);
52
+ if (!fact.agentId) throw new Error('scorecard: agentId required');
53
+ const e = {
54
+ v: SCORECARD_VERSION,
55
+ seq: prev ? prev.seq + 1 : 0,
56
+ agentId: String(fact.agentId),
57
+ kind: fact.kind,
58
+ at: Number(fact.at) || now(),
59
+ ...(fact.projectId ? { projectId: String(fact.projectId) } : {}),
60
+ ...(fact.jobId ? { jobId: String(fact.jobId) } : {}),
61
+ ...(fact.runId ? { runId: String(fact.runId) } : {}),
62
+ ...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
63
+ ...(fact.model ? { model: String(fact.model) } : {}),
64
+ ...(fact.size ? { size: sizeOf(fact.size) } : {}),
65
+ ...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
66
+ ...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
67
+ ...(Array.isArray(fact.with) && fact.with.length ? { with: [...new Set(fact.with.map(String))].sort() } : {}),
68
+ ...(Array.isArray(fact.created) && fact.created.length ? { created: [...new Set(fact.created.map(String))] } : {}),
69
+ ...(fact.rating ? { rating: { by: String(fact.rating.by || 'person'), score: clamp01(fact.rating.score), ...(fact.rating.note ? { note: String(fact.rating.note).slice(0, 500) } : {}), ...(fact.rating.about != null ? { about: Number(fact.rating.about) } : {}) } } : {}),
70
+ ...(Array.isArray(fact.refs) && fact.refs.length ? { refs: fact.refs.map(String).slice(0, 12) } : {}),
71
+ ...(fact.error ? { error: String(fact.error).slice(0, 300) } : {}),
72
+ prev: prev ? prev.hash : null,
73
+ };
74
+ e.hash = await sha256(canonical(hashable(e)), { subtle });
75
+ return e;
76
+ }
77
+
78
+ const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
79
+ const sizeOf = (s) => ({ ms: Math.max(0, Math.round(Number(s.ms) || 0)), steps: Math.max(0, Math.round(Number(s.steps) || 0)), tools: Math.max(0, Math.round(Number(s.tools) || 0)), findings: Math.max(0, Math.round(Number(s.findings) || 0)), tokens: Math.max(0, Math.round(Number(s.tokens) || 0)) });
80
+
81
+ /** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
82
+ export async function verifyChain(entries, { subtle } = {}) {
83
+ let prev = null;
84
+ for (const e of entries || []) {
85
+ if (!e || typeof e !== 'object') return { ok: false, at: prev ? prev.seq + 1 : 0, why: 'not an entry' };
86
+ if ((prev ? prev.seq + 1 : 0) !== e.seq) return { ok: false, at: e.seq, why: 'seq' };
87
+ if ((prev ? prev.hash : null) !== e.prev) return { ok: false, at: e.seq, why: 'prev' };
88
+ const h = await sha256(canonical(hashable(e)), { subtle });
89
+ if (h !== e.hash) return { ok: false, at: e.seq, why: 'hash' };
90
+ prev = e;
91
+ }
92
+ return { ok: true, at: null, length: (entries || []).length };
93
+ }
94
+
95
+ /**
96
+ * The store's mark. `key` is raw bytes only the store holds; an HMAC-SHA-256 over the hash.
97
+ * Anyone can re-hash the chain; only the store can mark it, so an entry written elsewhere
98
+ * is honest about being unattested. Injectable `subtle` again.
99
+ */
100
+ export async function attest(entry, key, { subtle = globalThis.crypto?.subtle } = {}) {
101
+ const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
102
+ return { ...entry, sig: hex(await subtle.sign('HMAC', k, enc.encode(entry.hash))) };
103
+ }
104
+ export async function verifyAttested(entries, key, { subtle = globalThis.crypto?.subtle } = {}) {
105
+ const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
106
+ const out = [];
107
+ for (const e of entries || []) {
108
+ const sig = e?.sig ? new Uint8Array(e.sig.match(/../g).map((x) => parseInt(x, 16))) : null;
109
+ out.push(!!sig && await subtle.verify('HMAC', k, sig, enc.encode(e.hash)));
110
+ }
111
+ return { ok: out.every(Boolean), attested: out.filter(Boolean).length, of: out.length };
112
+ }
113
+
114
+ /** The card a recruiter reads. */
115
+ export function summarize(entries, { recent = 5 } = {}) {
116
+ const list = (entries || []).filter((e) => e && e.kind);
117
+ const done = list.filter((e) => e.kind === 'task.done');
118
+ const failed = list.filter((e) => e.kind === 'task.failed');
119
+ const sum = (k) => done.reduce((n, e) => n + (e.size?.[k] || 0), 0);
120
+ const largest = done.reduce((m, e) => Math.max(m, e.size?.steps || 0), 0);
121
+ const tools = new Set(); const withAgents = new Set(); const created = new Set();
122
+ const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
123
+ const models = new Map();
124
+ for (const e of list) {
125
+ for (const t of e.tools || []) tools.add(t);
126
+ for (const a of e.with || []) withAgents.add(a);
127
+ for (const a of e.created || []) created.add(a);
128
+ if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
129
+ if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
130
+ }
131
+ const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
132
+ const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
133
+ const recentRatings = ratings.slice(-recent);
134
+ const refs = [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent);
135
+ return {
136
+ agentId: list[0]?.agentId || null,
137
+ entries: list.length,
138
+ jobsDone: done.length,
139
+ jobsFailed: failed.length,
140
+ size: { ms: sum('ms'), steps: sum('steps'), tools: sum('tools'), findings: sum('findings'), tokens: sum('tokens'), largestSteps: largest },
141
+ tools: [...tools].sort(),
142
+ workedWith: [...withAgents].sort(),
143
+ created: [...created],
144
+ roles,
145
+ models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
146
+ rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
147
+ refs,
148
+ since: list[0]?.at || null,
149
+ last: list.at(-1)?.at || null,
150
+ head: list.at(-1)?.hash || null,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * How well an agent TYPE fits a job, with that type's record. `job.needs` is
156
+ * `{ skills[], tools[], grants[] }`; `type` carries `skills[]`, `tools[]`, `grants[]`; the
157
+ * summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
158
+ * without the tools cannot do the job), track record second, size third.
159
+ */
160
+ export function fit(job, type, summary = null) {
161
+ const needs = job?.needs || {};
162
+ const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
163
+ const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
164
+ const reasons = [];
165
+ const coverage = (want, has, label) => {
166
+ const w = (want || []).map((x) => String(x).toLowerCase());
167
+ if (!w.length) return 1;
168
+ const hit = w.filter((x) => has.has(x));
169
+ if (hit.length < w.length) reasons.push(`missing ${label}: ${w.filter((x) => !has.has(x)).join(', ')}`);
170
+ return hit.length / w.length;
171
+ };
172
+ const cSkills = coverage(needs.skills, skills, 'skills');
173
+ const cTools = coverage(needs.tools, tools, 'tools');
174
+ const cGrants = coverage(needs.grants, grants, 'grants');
175
+ const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
176
+ if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
177
+ let record = 0.5; // a fresh type is neither trusted nor distrusted
178
+ if (summary && summary.entries) {
179
+ const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
180
+ const rated = summary.rating.avg == null ? 0.5 : summary.rating.avg;
181
+ record = doneRate * 0.5 + rated * 0.5;
182
+ reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? `, rated ${Math.round(summary.rating.avg * 100)}%` : ''}`);
183
+ if (summary.roles.orchestrator + summary.roles.manager + summary.roles['manager-of-managers'] > 0) reasons.push(`has led: ${summary.roles.orchestrator} as orchestrator, ${summary.roles.manager} as manager`);
184
+ } else {
185
+ reasons.push('no record yet');
186
+ }
187
+ const wantSize = Number(job?.size?.steps) || 0;
188
+ const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
189
+ if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
190
+ const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
191
+ return { score, reasons, parts: { needs: needScore, record, size: sizeScore } };
192
+ }
package/src/server.js CHANGED
@@ -34,7 +34,8 @@ import { installTimestampedConsole } from './log.js';
34
34
  import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
35
35
  import { createMemoryStore } from './memory-store.js';
36
36
  import { createPrefsStore } from './prefs-store.js';
37
- import { createTeamStore } from './team-store.js';
37
+ import { createTeamStore, loadOrCreateKey as loadTeamKey } from './team-store.js';
38
+ import { createScorecardStore } from './scorecard-store.js';
38
39
  import { createHistoryStore } from './sqlite-store.js';
39
40
  import { ingestBackups } from './backup-ingest.js';
40
41
  import * as nerEngine from './ner-engine.js';
@@ -58,7 +59,7 @@ import * as openai from './openai.js';
58
59
  import * as responses from './responses.js';
59
60
  import * as anthropic from './anthropic.js';
60
61
 
61
- export const VERSION = '0.6.85';
62
+ export const VERSION = '0.6.87';
62
63
 
63
64
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
64
65
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -67,7 +68,8 @@ export const VERSION = '0.6.85';
67
68
  const historyStore = await createHistoryStore();
68
69
  const memoryStore = await createMemoryStore();
69
70
  const prefsStore = createPrefsStore();
70
- const teamStore = createTeamStore();
71
+ const scorecards = createScorecardStore({ key: loadTeamKey() });
72
+ const teamStore = createTeamStore({ scorecards });
71
73
  // Who is watching prefs change — a client with a live subscription is told the moment a
72
74
  // section is written by the other client, instead of waiting for its next focus.
73
75
  const prefsWatchers = new Set();
@@ -706,7 +708,7 @@ export function createGateway(cfg = loadConfig()) {
706
708
  // Client preferences travel between the extension and the desktop through here, and an
707
709
  // MCP server entry can carry an Authorization header — so READS are gated too, unlike
708
710
  // history and memory. A drive-by page must not learn what tools the user connected.
709
- if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams')) && !isAdminAuthorized(req)) {
711
+ if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams') || pathname.startsWith('/v1/agents')) && !isAdminAuthorized(req)) {
710
712
  return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
711
713
  }
712
714
  // The access log is who-read-what — sensitive, and writable only by the local MCP
@@ -820,6 +822,23 @@ export function createGateway(cfg = loadConfig()) {
820
822
  return undefined;
821
823
  }
822
824
 
825
+ // --- SCORECARDS. Every agent's attested record (scorecard-store.js): the chain, its card,
826
+ // whether it verifies; a person's rating appended from either client.
827
+ if (pathname === '/v1/agents/scorecards' && req.method === 'GET') return sendJson(res, 200, { ok: true, agents: scorecards.list() });
828
+ {
829
+ const m = /^\/v1\/agents\/([a-zA-Z0-9_.:@+-]{1,120})\/scorecard$/.exec(pathname);
830
+ if (m) {
831
+ const agentId = decodeURIComponent(m[1]);
832
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...(await scorecards.get(agentId)) });
833
+ if (req.method === 'POST') {
834
+ try {
835
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
836
+ const entry = await scorecards.append({ agentId, kind: 'rating', runId: body.runId, taskId: body.taskId, jobId: body.jobId, rating: { by: String(body.by || 'person').slice(0, 40), score: body.score, note: body.note, about: body.about }, refs: body.refs });
837
+ return sendJson(res, 200, { ok: true, entry });
838
+ } catch (e) { return sendJson(res, 400, { error: { message: `scorecard: ${e.message}`, type: 'scorecard_error' } }); }
839
+ }
840
+ }
841
+ }
823
842
  // --- TEAM RUNS. The board every client can read (team-store.js).
824
843
  // GET /v1/teams/runs[?limit&team] → { ok, runs } newest first, no boards
825
844
  // POST /v1/teams/runs { id, team, request, client } → { ok, run }
package/src/team-store.js CHANGED
@@ -34,7 +34,7 @@ export const STALE_AFTER_MS = 5 * 60_000;
34
34
  const RUN_ID_RE = /^[a-zA-Z0-9_-]{4,64}$/;
35
35
  const LIVE = new Set(LIVE_RUN_STATUSES);
36
36
 
37
- function loadOrCreateKey() {
37
+ export function loadOrCreateKey() {
38
38
  try { if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64'); } catch { /* regenerate */ }
39
39
  const key = randomBytes(32);
40
40
  mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
@@ -58,7 +58,8 @@ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)
58
58
  export function applyEvent(run, ev) { return foldRun(run, ev); }
59
59
 
60
60
  export class TeamStore {
61
- constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS } = {}) {
61
+ constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS, scorecards = null } = {}) {
62
+ this.scorecards = scorecards; // the agents' ledgers (scorecard-store.js), fed by task.scored
62
63
  this.path = storePath;
63
64
  this.now = now;
64
65
  this.staleAfterMs = staleAfterMs;
@@ -97,6 +98,9 @@ export class TeamStore {
97
98
  const v = clone({ ...run, events: undefined });
98
99
  delete v.events;
99
100
  v.stale = LIVE.has(run.status) && this.now() - run.lastEventAt > this.staleAfterMs;
101
+ // How long since the running client last wrote — a person who knows the process died
102
+ // does not have to wait for the stale mark to pick the run up.
103
+ v.quietMs = LIVE.has(run.status) ? Math.max(0, this.now() - run.lastEventAt) : 0;
100
104
  // Can a client pick this run up again? Not while its own client is live on it.
101
105
  v.resumable = isResumable(v);
102
106
  if (events) v.events = clone(run.events);
@@ -128,6 +132,8 @@ export class TeamStore {
128
132
  const ev = { seq: seq++, type: String(type), at: Number(at) || this.now(), payload };
129
133
  run.events.push(ev);
130
134
  applyEvent(run, ev);
135
+ // A finished task's fact goes to the member's scorecard — chained and attested there.
136
+ if (this.scorecards && ev.type === 'task.scored') this.scorecards.fromRunEvent(ev, run);
131
137
  for (const fn of this.watchers.get(run.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
132
138
  }
133
139
  this.save();