@chatpanel/gateway 0.6.80 → 0.6.81

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.80",
3
+ "version": "0.6.81",
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": {
package/src/server.js CHANGED
@@ -58,7 +58,7 @@ import * as openai from './openai.js';
58
58
  import * as responses from './responses.js';
59
59
  import * as anthropic from './anthropic.js';
60
60
 
61
- export const VERSION = '0.6.80';
61
+ export const VERSION = '0.6.81';
62
62
 
63
63
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
64
64
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -824,6 +824,9 @@ export function createGateway(cfg = loadConfig()) {
824
824
  // GET /v1/teams/runs/:id[?events=1] → { ok, run } the board, the tasks, the proposal
825
825
  // POST /v1/teams/runs/:id/events { events: [...] } → { ok, run } the running client appends
826
826
  // GET /v1/teams/runs/:id/events[?after=seq] (SSE) replay from `after`, then live
827
+ // POST /v1/teams/runs/:id/answer { threadId, text, by } → { ok, run } a person answers an ask (0.6.81)
828
+ // POST /v1/teams/runs/:id/decide { postId, status, by } → { ok, run } approve / reject a post
829
+ // POST /v1/teams/runs/:id/post { threadId, text, kind?, replyTo?, by } → { ok, run }
827
830
  // POST /v1/teams/runs/:id/stop → { ok, run } a stop request any client may make
828
831
  // DELETE /v1/teams/runs/:id → { ok, removed }
829
832
  if (pathname === '/v1/teams/runs' && req.method === 'GET') {
@@ -838,7 +841,7 @@ export function createGateway(cfg = loadConfig()) {
838
841
  }
839
842
  }
840
843
  {
841
- const m = /^\/v1\/teams\/runs\/([a-zA-Z0-9_-]{4,64})(\/events|\/stop)?$/.exec(pathname);
844
+ const m = /^\/v1\/teams\/runs\/([a-zA-Z0-9_-]{4,64})(\/events|\/stop|\/answer|\/decide|\/post)?$/.exec(pathname);
842
845
  if (m) {
843
846
  const id = m[1];
844
847
  const sub = m[2] || '';
@@ -851,6 +854,20 @@ export function createGateway(cfg = loadConfig()) {
851
854
  const run = teamStore.stop(id);
852
855
  return run ? sendJson(res, 200, { ok: true, run }) : sendJson(res, 404, { error: { message: `no run ${id}`, type: 'not_found' } });
853
856
  }
857
+ // The board, from a person on ANY client (0.6.81): answer an ask, decide on a post,
858
+ // post a note. Each is appended as events, so the running client's tail sees it.
859
+ if ((sub === '/answer' || sub === '/decide' || sub === '/post') && req.method === 'POST') {
860
+ try {
861
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
862
+ const by = String(body.by || 'person').slice(0, 40);
863
+ const run = sub === '/answer' ? teamStore.answer(id, { threadId: body.threadId, text: body.text, by })
864
+ : sub === '/decide' ? teamStore.decide(id, { postId: body.postId, status: body.status, by })
865
+ : teamStore.post(id, { threadId: body.threadId, text: body.text, by, kind: body.kind, replyTo: body.replyTo });
866
+ return sendJson(res, 200, { ok: true, run });
867
+ } catch (e) {
868
+ return sendJson(res, e.message.startsWith('no ') ? 404 : 400, { error: { message: `team run: ${e.message}`, type: 'team_error' } });
869
+ }
870
+ }
854
871
  if (sub === '/events' && req.method === 'POST') {
855
872
  try {
856
873
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
@@ -0,0 +1,309 @@
1
+ // VENDORED from @chatpanel/events/team-board.js — edit there, then copy over.
2
+ // The board — where a team's members meet: a message board, not a log.
3
+ //
4
+ // One board per run. A THREAD per task (and one per ask, discussion or proposal); POSTS in
5
+ // threads — a member's findings, its notes, a question, a person's answer, a draft, a
6
+ // decision — and REPLIES hanging off posts. Members do not read each other's transcripts:
7
+ // a task ends with FINDINGS (claims with the refs they came from, I-K1), those become posts
8
+ // in its thread, and a later task reads the threads it depends on, threaded and sized like a
9
+ // shielded tool result. A member that disagrees replies where it disagrees; a member that
10
+ // is stuck ASKS, and its task waits for a person — on either client — to answer. A person
11
+ // posting is a member posting, and a person's decision on any post wins.
12
+ //
13
+ // The board is the run's record: durable (every change is an event the run store folds with
14
+ // `foldBoard`), attributable per member, and — after a run — what can become draft briefs
15
+ // and be promoted on convergence (W7), rather than evaporating with the run.
16
+ //
17
+ // Findings are parsed GENEROUSLY from a model's answer through the structured layer, and a
18
+ // task whose answer cannot be read as findings is not lost: its whole answer becomes one
19
+ // `draft` finding. A member that only wrote prose still contributed.
20
+
21
+ import { defineSchema, describeSchema, coerce } from './structured.js';
22
+
23
+ export const FINDING_KINDS = Object.freeze(['claim', 'draft', 'link', 'question', 'answer']);
24
+ export const MAX_FINDINGS_PER_TASK = 40;
25
+ export const BOARD_TEXT_MAX = 12_000;
26
+
27
+ export const FINDINGS_SCHEMA = defineSchema({
28
+ name: 'findings',
29
+ purpose: 'what this task established, each item on its own with where it came from',
30
+ fields: {
31
+ findings: {
32
+ type: 'object[]', required: true, maxItems: MAX_FINDINGS_PER_TASK,
33
+ describe: 'one entry per fact, draft, link or open question — never a paragraph of several',
34
+ fields: {
35
+ kind: { type: 'enum', values: FINDING_KINDS, default: 'claim' },
36
+ text: { type: 'string', required: true, max: 2000 },
37
+ refs: { type: 'string[]', maxItems: 8, describe: 'record ids or URLs this rests on, when any' },
38
+ confidence: { type: 'number', describe: '0–1, how sure' },
39
+ },
40
+ },
41
+ },
42
+ nothing: { findings: [] },
43
+ });
44
+
45
+ /** The instruction appended to every task so the answer can be read as findings. */
46
+ export function findingsInstruction() {
47
+ return `When you are done, end your answer with your findings in this shape:\n${describeSchema(FINDINGS_SCHEMA)}`;
48
+ }
49
+
50
+ /**
51
+ * Read a task's answer into findings. The JSON block, when present; otherwise the whole
52
+ * answer as one draft — a member that only wrote prose still contributed.
53
+ */
54
+ export function parseFindings(text, { role, taskId } = {}) {
55
+ const raw = String(text || '').trim();
56
+ if (!raw) return [];
57
+ const got = coerce(raw, FINDINGS_SCHEMA);
58
+ const list = Array.isArray(got?.value?.findings) ? got.value.findings.filter((f) => f && String(f.text || '').trim()) : [];
59
+ const stamp = (f, i) => ({
60
+ id: `${taskId || 't'}:${i + 1}`,
61
+ kind: FINDING_KINDS.includes(f.kind) ? f.kind : 'claim',
62
+ text: String(f.text).trim().slice(0, 2000),
63
+ refs: Array.isArray(f.refs) ? f.refs.map(String).filter(Boolean).slice(0, 8) : [],
64
+ // Absent or zero reads as "not stated": a model that gives no number is not 0% sure.
65
+ confidence: Number.isFinite(Number(f.confidence)) && Number(f.confidence) > 0 ? Math.min(1, Number(f.confidence)) : null,
66
+ role: role || null,
67
+ taskId: taskId || null,
68
+ });
69
+ if (list.length) return list.slice(0, MAX_FINDINGS_PER_TASK).map(stamp);
70
+ // No JSON block: the prose is the finding. Strip a fenced JSON tail that failed to parse.
71
+ const prose = raw.replace(/```json[\s\S]*$/i, '').trim() || raw;
72
+ return [stamp({ kind: 'draft', text: prose.slice(0, 2000), refs: [] }, 0)];
73
+ }
74
+
75
+ // ── threads, posts, asks ────────────────────────────────────────────────────────────────
76
+
77
+ export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposal']);
78
+ export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'approved', 'rejected']);
79
+ export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision']);
80
+ export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
81
+ export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
82
+ export const RUNNER = 'runner';
83
+ export const PERSON = 'person';
84
+ export const MAX_POST_TEXT = 4000;
85
+
86
+ const clip = (t, n) => String(t || '').trim().slice(0, n);
87
+ const refsOf = (r) => (Array.isArray(r) ? r.map(String).filter(Boolean).slice(0, 8) : []);
88
+
89
+ /** The empty folded state — what a run record holds, what `foldBoard` grows. */
90
+ export function emptyBoardState() { return { threads: [], posts: [] }; }
91
+
92
+ /**
93
+ * Fold one board event into a state (pure; the gateway's run store and both clients use it).
94
+ * Ids dedupe: an answer posted through the gateway and echoed by the running client's runner
95
+ * arrives twice with one id and lands once.
96
+ */
97
+ export function foldBoard(state, ev) {
98
+ const s = state && Array.isArray(state.threads) && Array.isArray(state.posts) ? state : emptyBoardState();
99
+ const type = String(ev?.type || '');
100
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : ev || {};
101
+ if (type === 'board.thread' && p.thread?.id) {
102
+ if (!s.threads.some((t) => t.id === p.thread.id)) s.threads.push({ ...p.thread });
103
+ } else if (type === 'board.post' && p.post?.id) {
104
+ if (!s.posts.some((x) => x.id === p.post.id)) s.posts.push({ ...p.post });
105
+ const t = s.threads.find((x) => x.id === p.post.threadId);
106
+ if (t) { t.lastAt = p.post.at; t.lastBy = p.post.by; t.posts = (t.posts || 0) + 1; }
107
+ } else if (type === 'board.decision' && p.postId) {
108
+ const x = s.posts.find((q) => q.id === p.postId);
109
+ if (x) { x.status = p.status; x.decidedBy = p.by; x.decidedAt = p.at ?? ev?.at; }
110
+ } else if (type === 'board.thread-status' && p.threadId) {
111
+ const t = s.threads.find((x) => x.id === p.threadId);
112
+ if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; }
113
+ }
114
+ return s;
115
+ }
116
+
117
+ /**
118
+ * The live board a run works on. `state` seeds it (a resume); `onEvent` receives every
119
+ * change as the event the run store folds. The legacy findings API (`add`, `all`, `byTask`)
120
+ * stays: a finding is a post of kind `finding` in its task's thread.
121
+ */
122
+ export function createBoard({ now = () => Date.now(), newId = null, state = null, onEvent = null } = {}) {
123
+ const st = state && Array.isArray(state.threads) ? { threads: state.threads.map((t) => ({ ...t })), posts: (state.posts || []).map((x) => ({ ...x })) } : emptyBoardState();
124
+ const listeners = new Set();
125
+ let seq = st.posts.length + st.threads.length;
126
+ const mk = (prefix) => (newId ? newId(prefix) : `${prefix}_${(++seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`);
127
+ const say = (type, payload) => { const ev = { type, at: now(), ...payload }; if (onEvent) onEvent(type, ev); return ev; };
128
+ const threadOf = (id) => st.threads.find((t) => t.id === id);
129
+ const postOf = (id) => st.posts.find((x) => x.id === id);
130
+ const threadForTask = (taskId) => st.threads.find((t) => t.kind === 'task' && t.taskId === taskId);
131
+
132
+ const api = {
133
+ /** Open a thread. A task's thread is opened once; asking for it again returns it. */
134
+ openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null } = {}) {
135
+ if (kind === 'task' && taskId) { const had = threadForTask(taskId); if (had) return had; }
136
+ const thread = { id: id || mk('th'), kind: THREAD_KINDS.includes(kind) ? kind : 'discussion', taskId, title: clip(title, 200), by, status: THREAD_STATUSES.includes(status) ? status : 'open', at: now(), posts: 0, ...(ask ? { ask } : {}) };
137
+ st.threads.push(thread);
138
+ say('board.thread', { thread: { ...thread } });
139
+ return thread;
140
+ },
141
+ /** A post in a thread; `replyTo` makes it a reply. */
142
+ post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null } = {}) {
143
+ const t = threadOf(threadId);
144
+ if (!t) throw new Error(`no thread ${threadId}`);
145
+ if (id && postOf(id)) return postOf(id);
146
+ const post = {
147
+ id: id || mk('p'), threadId, by: String(by || RUNNER), kind: POST_KINDS.includes(kind) ? kind : 'note',
148
+ text: clip(text, MAX_POST_TEXT), refs: refsOf(refs), replyTo, status: POST_STATUSES.includes(status) ? status : 'open', at: now(),
149
+ ...(finding ? { finding } : {}), ...(ask ? { ask } : {}),
150
+ };
151
+ st.posts.push(post);
152
+ t.lastAt = post.at; t.lastBy = post.by; t.posts = (t.posts || 0) + 1;
153
+ say('board.post', { post: { ...post } });
154
+ for (const l of listeners) l(post);
155
+ return post;
156
+ },
157
+ /** A reply hangs off a post and lives in that post's thread. */
158
+ reply({ postId, ...args }) {
159
+ const target = postOf(postId || args.replyTo);
160
+ if (!target) throw new Error(`no post ${postId}`);
161
+ return api.post({ ...args, threadId: target.threadId, replyTo: target.id });
162
+ },
163
+ /** A person's (or the judge's) decision on a post. */
164
+ decide(postId, status, by = PERSON) {
165
+ const x = postOf(postId);
166
+ if (!x || !['approved', 'rejected', 'proposed', 'open'].includes(status)) return null;
167
+ x.status = status; x.decidedBy = by; x.decidedAt = now();
168
+ say('board.decision', { postId, status, by, at: x.decidedAt });
169
+ return x;
170
+ },
171
+ setThreadStatus(threadId, status, extra = {}) {
172
+ const t = threadOf(threadId);
173
+ if (!t || !THREAD_STATUSES.includes(status)) return null;
174
+ t.status = status; if (status !== 'waiting') t.waitingOn = null; Object.assign(t, extra);
175
+ say('board.thread-status', { threadId, status, ...extra });
176
+ return t;
177
+ },
178
+ /**
179
+ * A member is stuck: open an ask thread (status `waiting`) with the question as its
180
+ * first post. The runner waits on it; a person answers from either client.
181
+ */
182
+ ask({ taskId = null, by, type = 'info', text, options = [], timeoutMs = 0, title = '' } = {}) {
183
+ const ask = { type: ASK_TYPES.includes(type) ? type : 'info', options: (Array.isArray(options) ? options : []).map(String).slice(0, 6), timeoutMs };
184
+ const thread = api.openThread({ taskId, kind: 'ask', title: title || clip(text, 120), by, status: 'waiting', ask });
185
+ thread.waitingOn = PERSON;
186
+ const post = api.post({ threadId: thread.id, by, kind: 'question', text, ask });
187
+ return { thread, post };
188
+ },
189
+ /** The answer to an ask — from a person, on any client. Resolves the thread. */
190
+ answer(threadId, { id = null, text, by = PERSON } = {}) {
191
+ const t = threadOf(threadId);
192
+ if (!t) return null;
193
+ const post = api.post({ id, threadId, by, kind: 'answer', text });
194
+ api.setThreadStatus(threadId, 'resolved', { answeredAt: post.at });
195
+ return post;
196
+ },
197
+ thread: threadOf,
198
+ threadForTask,
199
+ threads: () => st.threads.map((t) => ({ ...t })),
200
+ posts: (threadId = null) => st.posts.filter((x) => !threadId || x.threadId === threadId).map((x) => ({ ...x })),
201
+ postById: postOf,
202
+ /** Every ask still waiting — what a client pins at the top. */
203
+ waiting: () => st.threads.filter((t) => t.kind === 'ask' && t.status === 'waiting').map((t) => ({ ...t })),
204
+ /** The whole board, for the run record and for a resume. */
205
+ state: () => ({ threads: st.threads.map((t) => ({ ...t })), posts: st.posts.map((x) => ({ ...x })) }),
206
+
207
+ // ── findings, as before: a finding is a post in its task's thread ──
208
+ add(list, { by = null } = {}) {
209
+ let n = 0;
210
+ for (const f of Array.isArray(list) ? list : [list]) {
211
+ if (!f || !f.text) continue;
212
+ const t = f.taskId ? (threadForTask(f.taskId) || api.openThread({ taskId: f.taskId, kind: 'task', title: f.taskId })) : (st.threads.find((x) => x.kind === 'discussion' && x.title === 'findings') || api.openThread({ kind: 'discussion', title: 'findings' }));
213
+ api.post({ id: f.id, threadId: t.id, by: by || f.role || RUNNER, kind: 'finding', text: f.text, refs: f.refs, finding: { kind: f.kind || 'claim', confidence: f.confidence ?? null } });
214
+ n += 1;
215
+ }
216
+ return n;
217
+ },
218
+ all: () => st.posts.filter((x) => x.kind === 'finding').map(asFinding(st)),
219
+ byTask: (taskId) => api.all().filter((f) => f.taskId === taskId),
220
+ byRole: (role) => api.all().filter((f) => f.role === role),
221
+ onFinding(fn) { const l = (p) => { if (p.kind === 'finding') fn(asFinding(st)(p)); }; listeners.add(l); return () => listeners.delete(l); },
222
+ get size() { return st.posts.filter((x) => x.kind === 'finding').length; },
223
+ };
224
+ return api;
225
+ }
226
+
227
+ /** A finding post in the legacy finding shape (what merge, briefs and lanes read). */
228
+ function asFinding(st) {
229
+ return (p) => {
230
+ const t = st.threads.find((x) => x.id === p.threadId);
231
+ return { id: p.id, kind: p.finding?.kind || 'claim', text: p.text, refs: p.refs || [], confidence: p.finding?.confidence ?? null, role: p.by, taskId: t?.taskId || null, at: p.at, status: p.status };
232
+ };
233
+ }
234
+
235
+ /** Findings from a folded state (a run record read from the store). */
236
+ export function findingsOf(state) {
237
+ const st = state && Array.isArray(state.posts) ? state : emptyBoardState();
238
+ return st.posts.filter((x) => x.kind === 'finding').map(asFinding(st));
239
+ }
240
+
241
+ /**
242
+ * What a later task READS: the findings of the tasks it depends on (or everything so far),
243
+ * sized. Newest are kept whole; the oldest are what get cut, and the cut is stated.
244
+ */
245
+ export function boardText(source, { taskIds = null, max = BOARD_TEXT_MAX, role = null } = {}) {
246
+ // A board (threads + posts) reads threaded; a bare findings array reads as before.
247
+ const state = source && typeof source.state === 'function' ? source.state() : (source && Array.isArray(source.posts) ? source : null);
248
+ const lines = state ? threadedLines(state, { taskIds, role }) : findingLines(source, taskIds);
249
+ if (!lines.length) return '';
250
+ const head = state ? 'The board so far' : 'Findings so far';
251
+ const out = lines.join('\n');
252
+ if (out.length <= max) return `${head}:\n${out}`;
253
+ const kept = [];
254
+ let size = 0;
255
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
256
+ if (size + lines[i].length + 1 > max) break;
257
+ kept.unshift(lines[i]);
258
+ size += lines[i].length + 1;
259
+ }
260
+ return `${head} (${lines.length - kept.length} earlier lines omitted for length):\n${kept.join('\n')}`;
261
+ }
262
+
263
+ function findingLines(findings, taskIds) {
264
+ return (findings || []).filter((f) => !taskIds || taskIds.includes(f.taskId))
265
+ .map((f) => `- [${f.kind}${f.role ? ` · ${f.role}` : ''}${f.confidence != null ? ` · ${Math.round(f.confidence * 100)}%` : ''}] ${f.text}${f.refs?.length ? ` (refs: ${f.refs.join(', ')})` : ''}`);
266
+ }
267
+
268
+ /**
269
+ * What a member READS: the task threads it depends on (or every task thread), the answered
270
+ * asks and settled discussions — never rejected posts — threaded, a reply under its post.
271
+ * A person's decision is marked so a member treats it as settled.
272
+ */
273
+ function threadedLines(state, { taskIds, role }) {
274
+ const out = [];
275
+ const posts = state.posts;
276
+ const threads = state.threads.filter((t) => {
277
+ if (t.kind === 'task') return !taskIds || taskIds.includes(t.taskId);
278
+ if (t.kind === 'ask') return t.status === 'resolved' && (!role || t.by === role || t.by === RUNNER);
279
+ if (t.kind === 'discussion') return true;
280
+ return false; // a proposal is the run's output, not a member's input
281
+ });
282
+ for (const t of threads) {
283
+ const own = posts.filter((x) => x.threadId === t.id && x.status !== 'rejected');
284
+ if (!own.length) continue;
285
+ out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
286
+ const line = (x, depth) => {
287
+ const tag = [x.kind === 'finding' ? (x.finding?.kind || 'claim') : x.kind, x.by, x.finding?.confidence != null ? `${Math.round(x.finding.confidence * 100)}%` : null, x.status === 'approved' ? 'APPROVED' : x.status === 'proposed' ? 'proposed' : null, x.kind === 'decision' || x.by === PERSON ? 'SETTLED' : null].filter(Boolean).join(' · ');
288
+ out.push(`${' '.repeat(depth)}- [${tag}] ${x.text}${x.refs?.length ? ` (refs: ${x.refs.join(', ')})` : ''}`);
289
+ for (const r of own.filter((y) => y.replyTo === x.id)) line(r, depth + 1);
290
+ };
291
+ for (const x of own.filter((y) => !y.replyTo)) line(x, 0);
292
+ }
293
+ return out;
294
+ }
295
+
296
+ /** Findings → the claim shape a draft brief takes (W7): text + refs as `{ kind, id }`. */
297
+ export function toBriefClaims(findings) {
298
+ return (findings || [])
299
+ .filter((f) => f.kind === 'claim' && f.text)
300
+ .map((f) => ({
301
+ text: f.text,
302
+ refs: (f.refs || []).map((r) => {
303
+ const m = /^([a-z][a-z0-9_-]*):(?!\/\/)(.+)$/.exec(String(r));
304
+ return m ? { kind: m[1], id: m[2] } : { kind: 'url', id: String(r) };
305
+ }),
306
+ by: f.role || 'team',
307
+ confidence: f.confidence,
308
+ }));
309
+ }
package/src/team-store.js CHANGED
@@ -20,6 +20,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from '
20
20
  import { join, dirname } from 'node:path';
21
21
  import os from 'node:os';
22
22
  import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
23
+ import { foldBoard, emptyBoardState } from './team-board.js';
23
24
 
24
25
  const DIR = join(os.homedir(), '.chatpanel');
25
26
  const STORE_PATH = process.env.CHATPANEL_TEAMS_STORE || join(DIR, 'team-runs.enc');
@@ -79,8 +80,18 @@ export function applyEvent(run, ev) {
79
80
  run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = ev.at;
80
81
  break;
81
82
  case 'run.stop-requested': run.stopRequested = ev.at; break;
83
+ // The board as a message board (events 0.81): threads, posts, replies, decisions, asks.
84
+ // Folded by the shared fold, so this record and both clients agree on it.
85
+ case 'board.thread': case 'board.post': case 'board.decision': case 'board.thread-status':
86
+ run.threads = foldBoard(run.threads || emptyBoardState(), ev);
87
+ if (type === 'board.thread-status' && p.status !== 'waiting' && run.status === 'waiting' && !(run.threads.threads || []).some((t) => t.kind === 'ask' && t.status === 'waiting')) run.status = 'answered';
88
+ break;
89
+ case 'task.waiting': { const t = run.tasks.find((x) => x.id === p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
90
+ case 'run.waiting': run.status = 'running'; break;
91
+ case 'run.resumed': run.status = 'running'; run.endedAt = null; run.checkpoint = null; break;
82
92
  default: break;
83
93
  }
94
+ if (type === 'run.done' && p.checkpoint) run.checkpoint = p.checkpoint;
84
95
  return run;
85
96
  }
86
97
 
@@ -112,7 +123,7 @@ export class TeamStore {
112
123
  renameSync(tmp, this.path);
113
124
  }
114
125
  _fresh(id, { client = '' } = {}) {
115
- return { id, client: String(client || '').slice(0, 40), createdAt: this.now(), lastEventAt: this.now(), status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], proposal: null, usage: null, stopRequested: null, events: [] };
126
+ return { id, client: String(client || '').slice(0, 40), createdAt: this.now(), lastEventAt: this.now(), status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, events: [] };
116
127
  }
117
128
  _evict() {
118
129
  if (this.runs.size <= MAX_RUNS) return;
@@ -165,9 +176,43 @@ export class TeamStore {
165
176
  .filter((r) => !team || r.team === team)
166
177
  .sort((a, b) => b.createdAt - a.createdAt)
167
178
  .slice(0, Math.max(1, Math.min(200, Number(limit) || 50)))
168
- .map((r) => { const v = this._view(r); return { ...v, board: undefined, tasks: v.tasks.map((t) => ({ ...t, text: undefined })), findings: r.board.length }; });
179
+ .map((r) => { const v = this._view(r); return { ...v, board: undefined, threads: undefined, checkpoint: undefined, tasks: v.tasks.map((t) => ({ ...t, text: undefined })), findings: r.board.length, waiting: (r.threads?.threads || []).filter((t) => t.kind === 'ask' && t.status === 'waiting').length }; });
169
180
  }
170
181
  /** Ask the running client to stop. Recorded as an event, so watchers (the runner) see it. */
182
+ /**
183
+ * A person's answer to an ask, from ANY client: an answer post and the thread resolved,
184
+ * appended as the events the running client's tail turns into the member's answer.
185
+ * The post id is fixed here so the runner's own echo of it lands once.
186
+ */
187
+ answer(id, { threadId, text, by = 'person' } = {}) {
188
+ const run = this.runs.get(String(id || ''));
189
+ if (!run) throw new Error(`no run ${id}`);
190
+ const thread = (run.threads?.threads || []).find((t) => t.id === threadId);
191
+ if (!thread) throw new Error(`no thread ${threadId}`);
192
+ if (thread.kind !== 'ask') throw new Error(`thread ${threadId} is not an ask`);
193
+ const at = this.now();
194
+ const postId = `ans_${randomBytes(4).toString('hex')}`;
195
+ const post = { id: postId, threadId, by: String(by || 'person').slice(0, 40), kind: 'answer', text: String(text || '').slice(0, 4000), refs: [], replyTo: null, status: 'open', at };
196
+ return this.append(id, [{ type: 'board.post', at, post }, { type: 'board.thread-status', at, threadId, status: 'resolved', answeredAt: at }]);
197
+ }
198
+ /** A person's decision on a post (approve / reject the draft, a finding), from any client. */
199
+ decide(id, { postId, status, by = 'person' } = {}) {
200
+ const run = this.runs.get(String(id || ''));
201
+ if (!run) throw new Error(`no run ${id}`);
202
+ if (!['approved', 'rejected', 'proposed', 'open'].includes(status)) throw new Error('status must be approved, rejected, proposed or open');
203
+ if (!(run.threads?.posts || []).some((x) => x.id === postId)) throw new Error(`no post ${postId}`);
204
+ const at = this.now();
205
+ return this.append(id, [{ type: 'board.decision', at, postId, status, by: String(by || 'person').slice(0, 40) }]);
206
+ }
207
+ /** A person's own post in a thread (a note, a question), from any client. */
208
+ post(id, { threadId, text, by = 'person', kind = 'note', replyTo = null } = {}) {
209
+ const run = this.runs.get(String(id || ''));
210
+ if (!run) throw new Error(`no run ${id}`);
211
+ if (!(run.threads?.threads || []).some((t) => t.id === threadId)) throw new Error(`no thread ${threadId}`);
212
+ const at = this.now();
213
+ const post = { id: `pp_${randomBytes(4).toString('hex')}`, threadId, by: String(by || 'person').slice(0, 40), kind: ['note', 'question', 'decision'].includes(kind) ? kind : 'note', text: String(text || '').slice(0, 4000), refs: [], replyTo: replyTo || null, status: 'open', at };
214
+ return this.append(id, [{ type: 'board.post', at, post }]);
215
+ }
171
216
  stop(id) {
172
217
  const run = this.runs.get(String(id || ''));
173
218
  if (!run) return null;