@chatpanel/events 0.89.3 → 0.90.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.
package/index.js CHANGED
@@ -212,7 +212,8 @@ export { DEFAULT_MIN_CALLS, cardOverride, applyCard } from './model-candidates.j
212
212
  export { SCM_KINDS, validateConnection, normalizeConnection, parseRemote, connectionFor, branchFor, worktreeDirFor, credentialEnv, describeConnection, blankConnection, connectionFromForm } from './scm-connection.js';
213
213
  export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
214
214
  export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
215
- export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
215
+ export { teamToolProvider, teamToolSpec, teamToolTimeoutMs, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
216
+ export { workLogFor, workLogText, workLogEvidence, describeCall, WORKLOG_KINDS } from './team-worklog.js';
216
217
  export { teamLine, teamLanes } from './team-trail.js';
217
218
  export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
218
219
  export { createManifest, ManifestError, SOURCES } from './manifest.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.89.3",
3
+ "version": "0.90.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",
@@ -107,6 +107,7 @@
107
107
  "./team-run.js": "./team-run.js",
108
108
  "./team-tool.js": "./team-tool.js",
109
109
  "./team-trail.js": "./team-trail.js",
110
+ "./team-worklog.js": "./team-worklog.js",
110
111
  "./team.js": "./team.js",
111
112
  "./text-search.js": "./text-search.js",
112
113
  "./theme.js": "./theme.js",
@@ -238,6 +239,7 @@
238
239
  "team-run.js",
239
240
  "team-tool.js",
240
241
  "team-trail.js",
242
+ "team-worklog.js",
241
243
  "team.js",
242
244
  "text-search.js",
243
245
  "theme.js",
package/team-board.js CHANGED
@@ -74,7 +74,9 @@ export function parseFindings(text, { role, taskId } = {}) {
74
74
  // ── threads, posts, asks ────────────────────────────────────────────────────────────────
75
75
 
76
76
  export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposal']);
77
- export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'approved', 'rejected']);
77
+ // `failed`: the task behind the thread ended without an answer (every model on the roster
78
+ // tried, or a hard error) — not `resolved`, which read as "done" on the board.
79
+ export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'failed', 'approved', 'rejected']);
78
80
  export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision']);
79
81
  export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
80
82
  export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
package/team-run.js CHANGED
@@ -23,7 +23,7 @@ import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
23
23
  import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
24
24
  import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
25
25
  import { createRunCache, withRunCache } from './team-cache.js';
26
- import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote } from './team-task.js';
26
+ import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote, isThought } from './team-task.js';
27
27
  import { converge } from './promotion.js';
28
28
 
29
29
  export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
@@ -58,7 +58,11 @@ export function isModelUnavailable(error) {
58
58
  // Also: a relayed agent that exited, a provider that closed the stream, and a turn that
59
59
  // came back with nothing — the model did not answer, and the next one might. A refusal,
60
60
  // a timeout the caller set, a bad request or a budget stop are not this.
61
- return /model[_ ]not[_ ]found|not found|not deployed|inaccessible|does not exist|no such model|unknown model|unsupported model|not available|unavailable|no api key|not configured|"status":\s*(404|401|403|500|502|503)\b|\b(404|401|403|502|503)\b|exited \d+|returned no answer|did not answer|closed the connection|couldn't reach|could not reach|ECONNREFUSED|overloaded|capacity/i.test(m);
61
+ // A server that is not there: Node says ECONNREFUSED, a browser says only "Failed to
62
+ // fetch" (the extension reports it as "network error") — a local model killed mid-run
63
+ // arrived as the latter and ended the task on its first attempt with Claude Code sitting
64
+ // idle on the roster.
65
+ return /model[_ ]not[_ ]found|not found|not deployed|inaccessible|does not exist|no such model|unknown model|unsupported model|not available|unavailable|no api key|not configured|"status":\s*(404|401|403|500|502|503)\b|\b(404|401|403|502|503)\b|exited \d+|returned no answer|did not answer|closed the connection|couldn't reach|could not reach|ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|socket hang up|fetch failed|failed to fetch|load failed|network ?error|overloaded|capacity/i.test(m);
62
66
  }
63
67
 
64
68
  export function dryRunTeam(team, request, { appoint = null } = {}) {
@@ -235,9 +239,13 @@ export async function runTeam({
235
239
  if (req.type !== 'handoff' || req.taskId !== task.id) return false;
236
240
  handoffTo = req; taskAc.abort(); return true;
237
241
  }) || null;
242
+ // Every recorded step says when and under which attempt — the board's work log and the
243
+ // scorecard read the task by these, so a step reported after the fact is stamped now.
244
+ let attemptNo = 0;
245
+ const stamp = (m) => clipTranscriptOne({ ...m, at: Number.isFinite(m?.at) ? m.at : now(), attempt: Number.isFinite(m?.attempt) ? m.attempt : attemptNo });
238
246
  const recordSteps = (before, after) => {
239
247
  const added = newSteps(before, after);
240
- if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map((m) => clipTranscriptOne(m)) });
248
+ if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map(stamp) });
241
249
  };
242
250
  try {
243
251
  if (role.mode === 'recipe') {
@@ -295,13 +303,16 @@ export async function runTeam({
295
303
  routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
296
304
  say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
297
305
  attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
306
+ attemptNo = attempts.length;
298
307
  const sent = messagesFor({ transcript }, { prompt, note });
299
308
  // The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
300
309
  // moment it exists (a tool call, its result) puts it on the record then, so a
301
310
  // process that dies mid-attempt leaves the work so far behind it, not nothing.
302
311
  let live = sent;
303
- if (sent.length > transcript.length) recordSteps(transcript, sent);
304
- const onStep = (message) => { if (message && message.role) { live = [...live, message]; say('task.step', { taskId: task.id, role: role.id, steps: [clipTranscriptOne(message)] }); } };
312
+ // (thoughts are on the record but not on the wire, so compare against the wire's view)
313
+ const wireBefore = transcript.filter((m) => !isThought(m));
314
+ if (sent.length > wireBefore.length) recordSteps(wireBefore, sent);
315
+ const onStep = (message) => { if (message && message.role) { live = [...live, message]; say('task.step', { taskId: task.id, role: role.id, steps: [stamp(message)] }); } };
305
316
  const res = await callModel({
306
317
  runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
307
318
  system: role.prompt, prompt, messages: sent, tools, signal: taskAc.signal,
@@ -355,7 +366,13 @@ export async function runTeam({
355
366
  const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
356
367
  if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
357
368
  const thread = board.threadForTask(task.id);
358
- if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
369
+ // The thread says how the task ended. A failure is posted in it as well — a person reading
370
+ // the board sees "researcher failed: network error" where it happened, and what was tried.
371
+ if (thread && status === 'ok') board.setThreadStatus(thread.id, 'resolved');
372
+ else if (thread && status !== 'waiting') {
373
+ board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role.id} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.model).join(', ')})` : ''}.` });
374
+ board.setThreadStatus(thread.id, 'failed');
375
+ }
359
376
  const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
360
377
  tasksOut.push(row);
361
378
  say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
package/team-task.js CHANGED
@@ -26,9 +26,20 @@ export function clipMessage(m) {
26
26
  if (m.tool_calls) out.tool_calls = m.tool_calls.map((c) => ({ id: c.id, type: c.type || 'function', function: { name: c.function?.name, arguments: clipStr(String(c.function?.arguments ?? ''), STEP_MAX_CHARS) } }));
27
27
  if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
28
28
  if (m.name) out.name = m.name;
29
+ // The member's reasoning, when the model streams it: on the record for the board and the
30
+ // scorecard, never sent back on the wire (messagesFor drops it).
31
+ if (typeof m.thought === 'string') out.thought = clipStr(m.thought, STEP_MAX_CHARS);
32
+ // When the step happened and under which attempt — the work log orders by these.
33
+ if (Number.isFinite(m.at)) out.at = m.at;
34
+ if (Number.isFinite(m.attempt)) out.attempt = m.attempt;
29
35
  return out;
30
36
  }
31
37
 
38
+ /** A step that is the member thinking aloud — no content, no call — recorded, not replayed. */
39
+ export function isThought(m) {
40
+ return !!m && m.role === 'assistant' && typeof m.thought === 'string' && m.content == null && !(Array.isArray(m.tool_calls) && m.tool_calls.length);
41
+ }
42
+
32
43
  /** The record's copy of a transcript: clipped per message and bounded as a whole (oldest tool traffic goes first). */
33
44
  export function clipTranscript(messages) {
34
45
  const list = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role && m.role !== 'system').map(clipMessage);
@@ -67,7 +78,7 @@ export function continuationNote({ kind = 'handoff', from = '', to = '', reason
67
78
  * it first) so a transcript never carries a role prompt that a later role might not share.
68
79
  */
69
80
  export function messagesFor(task, { prompt, note = null } = {}) {
70
- const transcript = Array.isArray(task?.transcript) ? task.transcript : [];
81
+ const transcript = (Array.isArray(task?.transcript) ? task.transcript : []).filter((m) => !isThought(m));
71
82
  if (!transcript.length) return [{ role: 'user', content: String(prompt || '') }];
72
83
  const last = transcript[transcript.length - 1];
73
84
  // A transcript that ends in an unanswered tool call cannot be continued as-is: close it.
package/team-tool.js CHANGED
@@ -26,10 +26,23 @@ function catalogue(teams) {
26
26
  return `Saved teams: ${list.map((t) => `${t.name} (${(t.roles || []).map((r) => r.id).join(', ')})${t.description ? ` — ${t.description}` : ''}`).join('; ')}.`;
27
27
  }
28
28
 
29
+ /**
30
+ * How long one call of the tool may take: the longest budget among the teams plus the merge,
31
+ * never under two minutes. A relay between a CLI agent and this tool (the bridge's MCP
32
+ * server) times a call by this; without it a 300 s team hit the relay's 120 s default,
33
+ * Claude Code was told "tool call timed out" and ran the team AGAIN while the first run was
34
+ * still working.
35
+ */
36
+ export function teamToolTimeoutMs(teams) {
37
+ const longest = Math.max(0, ...(teams || []).map((t) => Number(t?.budget?.ms) || 0));
38
+ return Math.max(120_000, (longest || 10 * 60_000) + 60_000);
39
+ }
40
+
29
41
  export function teamToolSpec(teams) {
30
42
  return {
31
43
  name: TEAM_TOOL_NAME,
32
44
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
45
+ timeoutMs: teamToolTimeoutMs(teams),
33
46
  description:
34
47
  `Saved agent teams — several roles working a request in parallel, merged into one answer. ${catalogue(teams)} `
35
48
  + 'Actions: {"action":"run","name":"<team>","request":"<what to do>"} runs one (streams; may take a while); '
@@ -0,0 +1,146 @@
1
+ // The work log — a task's thread as the record of the work, not just its posts.
2
+ //
3
+ // A task is a conversation (team-task.js): every step is on the record as it happens — the
4
+ // prompt, the member's text, its reasoning when the model streams it, each tool call and
5
+ // what came back — beside the attempts (which model, when, how it ended), the hand-offs, the
6
+ // posts in its thread, and how the task ended. The board drew the posts alone, and a task
7
+ // that failed before its first post looked like nothing had happened. This folds all of it
8
+ // into ONE ordered timeline, the same in both clients, so what a member did, tried, was told
9
+ // and produced is read in one place — by a person, by the next wave, and by whoever rates
10
+ // the member (evidence first: calls made, findings kept, models burned, budget spent).
11
+ //
12
+ // Pure: a run record (team-record.js) in, entries out. No rendering here.
13
+
14
+ import { isThought } from './team-task.js';
15
+
16
+ export const WORKLOG_KINDS = Object.freeze(['attempt', 'prompt', 'note', 'thought', 'text', 'call', 'result', 'handoff', 'post', 'end']);
17
+
18
+ const num = (v, d = 0) => (Number.isFinite(v) ? v : d);
19
+ const str = (v) => (v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v));
20
+
21
+ function parseArgs(raw) {
22
+ if (raw && typeof raw === 'object') return raw;
23
+ try { return JSON.parse(String(raw || '{}')); } catch { return { raw: String(raw || '') }; }
24
+ }
25
+
26
+ /** One line for a tool call: `find web_search "…"` — the dispatcher's action stands in front. */
27
+ export function describeCall(call) {
28
+ const name = call?.function?.name || call?.name || 'tool';
29
+ const args = parseArgs(call?.function?.arguments ?? call?.arguments);
30
+ const action = args?.action ? ` ${args.action}` : '';
31
+ const inner = args?.args && typeof args.args === 'object' ? args.args : args;
32
+ const first = inner && typeof inner === 'object' ? Object.entries(inner).find(([k, v]) => k !== 'action' && k !== 'args' && (typeof v === 'string' || typeof v === 'number')) : null;
33
+ return `${name}${action}${first ? ` ${first[0]}=${JSON.stringify(String(first[1]).slice(0, 120))}` : ''}`;
34
+ }
35
+
36
+ /**
37
+ * The timeline of one task: `[{ kind, at, by, attempt, ... }]`, oldest first. Every entry has
38
+ * an `at`; a step recorded without one (an older build) inherits its attempt's, so the order
39
+ * still holds. `by` is the role for what the member did, `runner` for the runner's lines, and
40
+ * whoever posted for a post.
41
+ */
42
+ export function workLogFor(run, taskId) {
43
+ const task = (run?.tasks || []).find((t) => t.id === taskId);
44
+ if (!task) return [];
45
+ const by = task.role || task.id;
46
+ const out = [];
47
+ const attempts = Array.isArray(task.attempts) ? task.attempts : [];
48
+ const baseAt = num(task.startedAt, num(attempts[0]?.at, num(run?.startedAt, 0)));
49
+ attempts.forEach((a, i) => out.push({ kind: 'attempt', at: num(a.at, baseAt + i), by: 'runner', attempt: i + 1, model: a.model || '', engine: a.engine || null, continued: !!a.continued, status: a.status || null, error: a.error || null }));
50
+
51
+ // Steps: stamped ones sort by their time; unstamped ones follow their attempt in order.
52
+ const steps = Array.isArray(task.transcript) ? task.transcript : [];
53
+ const calls = new Map();
54
+ let lastAt = baseAt;
55
+ let attemptOf = 1;
56
+ steps.forEach((m, i) => {
57
+ if (!m || !m.role) return;
58
+ if (Number.isFinite(m.attempt)) attemptOf = m.attempt;
59
+ else if (attempts.length) attemptOf = Math.max(attemptOf, 1 + attempts.findLastIndex((x) => num(x.at, 0) <= lastAt + 1));
60
+ // Unstamped: after everything before it, and after its attempt began.
61
+ const at = Number.isFinite(m.at) ? m.at : Math.max(lastAt, num(attempts[attemptOf - 1]?.at, 0)) + 1;
62
+ lastAt = Math.max(lastAt, at);
63
+ const attempt = attemptOf;
64
+ if (m.role === 'user') {
65
+ out.push({ kind: i === 0 ? 'prompt' : 'note', at, by: 'runner', attempt, text: str(m.content) });
66
+ } else if (m.role === 'assistant') {
67
+ if (isThought(m)) { out.push({ kind: 'thought', at, by, attempt, text: m.thought }); return; }
68
+ if (typeof m.content === 'string' && m.content.trim()) out.push({ kind: 'text', at, by, attempt, text: m.content });
69
+ for (const c of Array.isArray(m.tool_calls) ? m.tool_calls : []) {
70
+ const id = c.id || `c${i}`;
71
+ calls.set(id, c);
72
+ out.push({ kind: 'call', at, by, attempt, callId: id, name: c.function?.name || 'tool', args: parseArgs(c.function?.arguments), text: describeCall(c) });
73
+ }
74
+ } else if (m.role === 'tool') {
75
+ const c = m.tool_call_id ? calls.get(m.tool_call_id) : null;
76
+ out.push({ kind: 'result', at, by: 'tool', attempt, callId: m.tool_call_id || null, name: c?.function?.name || m.name || 'tool', text: str(m.content), error: /^error[:\s]/i.test(str(m.content)) });
77
+ }
78
+ });
79
+
80
+ for (const h of Array.isArray(task.handoffs) ? task.handoffs : []) out.push({ kind: 'handoff', at: num(h.at, lastAt), by: h.by || 'person', from: h.from || '', to: h.to || '', text: h.reason || '' });
81
+
82
+ // The thread's posts — a member's findings, a question, an answer, a decision, the runner's notes.
83
+ const thread = (run?.threads?.threads || []).find((t) => t.taskId === taskId && t.kind === 'task');
84
+ if (thread) for (const p of (run.threads.posts || []).filter((x) => x.threadId === thread.id)) out.push({ kind: 'post', at: num(p.at, lastAt), by: p.by || '', post: p, text: p.text || '' });
85
+
86
+ // What the member is saying right now — the attempt's text before it is a step.
87
+ const lastText = [...out].reverse().find((e) => e.kind === 'text');
88
+ if (task.status === 'running' && task.text && task.text !== lastText?.text) out.push({ kind: 'text', at: num(run?.lastEventAt, lastAt + 1), by, attempt: attemptOf, text: task.text, live: true });
89
+
90
+ if (task.endedAt || ['ok', 'failed', 'over-budget', 'stopped', 'waiting'].includes(task.status)) {
91
+ // Findings: the task's count, or the finding posts in its thread — a member's board posts
92
+ // count as its answer (team-run.js), and a record folded from an older run has only those.
93
+ const findings = Math.max(num(task.findings, 0), out.filter((e) => e.kind === 'post' && e.post?.kind === 'finding').length);
94
+ const tools = Math.max(num(task.tools, 0), out.filter((e) => e.kind === 'call').length);
95
+ out.push({ kind: 'end', at: num(task.endedAt, num(run?.lastEventAt, lastAt + 2)), by: 'runner', status: task.status, error: task.error || null, ms: num(task.ms, 0), findings, tools, text: endText({ ...task, findings, tools }, attempts) });
96
+ }
97
+ return out.sort((a, b) => a.at - b.at || WORKLOG_KINDS.indexOf(a.kind) - WORKLOG_KINDS.indexOf(b.kind));
98
+ }
99
+
100
+ function endText(task, attempts) {
101
+ const tried = attempts.length > 1 ? ` after ${attempts.length} models (${attempts.map((a) => a.model).join(' → ')})` : '';
102
+ const s = task.status;
103
+ if (s === 'ok') return `done${tried} · ${task.findings || 0} finding${task.findings === 1 ? '' : 's'} · ${task.tools || 0} tool call${task.tools === 1 ? '' : 's'}`;
104
+ if (s === 'waiting') return 'waiting on a person';
105
+ if (s === 'over-budget') return `stopped at the budget${tried}`;
106
+ if (s === 'stopped') return `stopped${tried}`;
107
+ return `failed${tried}${task.error ? `: ${task.error}` : ''}`;
108
+ }
109
+
110
+ /** The log as text — for a brief, a rating, a test: one line per entry, results shortened. */
111
+ export function workLogText(entries, { resultChars = 200 } = {}) {
112
+ return (entries || []).map((e) => {
113
+ switch (e.kind) {
114
+ case 'attempt': return `▸ attempt ${e.attempt}: ${e.model}${e.continued ? ' (continues)' : ''}`;
115
+ case 'prompt': return `▸ task: ${e.text}`;
116
+ case 'note': return `▸ runner → ${e.text}`;
117
+ case 'thought': return `${e.by} (thinking): ${e.text}`;
118
+ case 'text': return `${e.by}: ${e.text}`;
119
+ case 'call': return `${e.by} → ${e.text}`;
120
+ case 'result': return ` ← ${e.name}: ${e.text.length > resultChars ? `${e.text.slice(0, resultChars)}…` : e.text}`;
121
+ case 'handoff': return `▸ handed from ${e.from} to ${e.to} by ${e.by}${e.text ? ` — ${e.text}` : ''}`;
122
+ case 'post': return `${e.by} posted ${e.post?.kind || 'note'}: ${e.text}`;
123
+ case 'end': return `▸ ${e.text}`;
124
+ default: return '';
125
+ }
126
+ }).filter(Boolean).join('\n');
127
+ }
128
+
129
+ /**
130
+ * What the log says about the work, as numbers — the evidence a rating starts from, before
131
+ * any opinion (a judge's, a peer's, a person's) is added beside it.
132
+ */
133
+ export function workLogEvidence(entries) {
134
+ const ev = { calls: 0, results: 0, resultErrors: 0, thoughts: 0, texts: 0, attempts: 0, handoffs: 0, posts: 0, findings: 0, decided: { approved: 0, rejected: 0 }, status: null, ms: 0 };
135
+ for (const e of entries || []) {
136
+ if (e.kind === 'call') ev.calls++;
137
+ else if (e.kind === 'result') { ev.results++; if (e.error) ev.resultErrors++; }
138
+ else if (e.kind === 'thought') ev.thoughts++;
139
+ else if (e.kind === 'text') ev.texts++;
140
+ else if (e.kind === 'attempt') ev.attempts++;
141
+ else if (e.kind === 'handoff') ev.handoffs++;
142
+ else if (e.kind === 'post') { ev.posts++; if (e.post?.kind === 'finding') ev.findings++; if (e.post?.status === 'approved') ev.decided.approved++; if (e.post?.status === 'rejected') ev.decided.rejected++; }
143
+ else if (e.kind === 'end') { ev.status = e.status; ev.ms = e.ms; ev.findings = Math.max(ev.findings, e.findings || 0); }
144
+ }
145
+ return ev;
146
+ }