@chatpanel/events 0.80.1 → 0.81.1

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/board-tool.js ADDED
@@ -0,0 +1,144 @@
1
+ // The `board` tool — how a member operates on the board.
2
+ //
3
+ // Every role gets it, whatever its grants: it reaches nothing outside the run. Four actions:
4
+ // `read` the threads this member may see, `post` a note or draft in a thread, `reply` to a
5
+ // post (agree, dispute with a ref, extend), and `ask` — open an ask thread and WAIT for a
6
+ // person to answer it, from either client. Members do not chat freely: a post is typed, a
7
+ // reply hangs off a post, and an ask pauses the member's own task, never the run.
8
+ //
9
+ // Bound per task: the member's role and task are fixed at bind time, so a member cannot post
10
+ // as someone else, and its ask lands in its own task's context.
11
+
12
+ import { boardText, POST_KINDS, ASK_TYPES } from './team-board.js';
13
+
14
+ export const BOARD_TOOL_NAME = 'board';
15
+ export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
16
+
17
+ export function boardToolSpec() {
18
+ return {
19
+ name: BOARD_TOOL_NAME,
20
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
21
+ description:
22
+ 'The team board — where the other members\' work is, and where you speak to them and to the user. '
23
+ + 'Actions: {"action":"read"} the threads you may see; '
24
+ + '{"action":"post","kind":"note|draft|question","text":"…","refs":["…"]} a post in your task\'s thread; '
25
+ + '{"action":"reply","postId":"…","kind":"note","text":"…","refs":["…"]} a reply to another member\'s post — agree, dispute with a ref, extend; '
26
+ + '{"action":"ask","type":"info|budget|permission|direction","text":"what you need and why","options":["…"]} asks the USER and waits for the answer (minutes). '
27
+ + 'Ask only when you are stuck — a fact you could not find, a choice only the user can make. Otherwise decide, say what you assumed, and go on.',
28
+ parameters: {
29
+ type: 'object',
30
+ properties: {
31
+ action: { type: 'string', enum: ['read', 'post', 'reply', 'ask'] },
32
+ kind: { type: 'string', enum: POST_KINDS.filter((k) => k !== 'finding' && k !== 'answer' && k !== 'decision') },
33
+ text: { type: 'string' },
34
+ refs: { type: 'array', items: { type: 'string' } },
35
+ postId: { type: 'string', description: 'For reply: the post to reply to.' },
36
+ threadId: { type: 'string', description: 'For post: another thread you may see (default: your task\'s).' },
37
+ type: { type: 'string', enum: [...ASK_TYPES], description: 'For ask.' },
38
+ options: { type: 'array', items: { type: 'string' }, description: 'For ask: choices to offer the user.' },
39
+ },
40
+ required: ['action'],
41
+ },
42
+ };
43
+ }
44
+
45
+ const json = (v) => JSON.stringify(v);
46
+
47
+ /**
48
+ * @param board the run's live board (team-board.js createBoard)
49
+ * @param role the member's role id
50
+ * @param taskId the member's task
51
+ * @param taskIds the tasks this member may read (its dependencies, or null for all)
52
+ * @param waitFor `async (threadId, timeoutMs, signal) => { text, by } | null` — the runner's answer box
53
+ * @param onAsk `(thread, post) => void` — the runner marks the task waiting
54
+ */
55
+ export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
56
+ const ownThread = () => board.threadForTask(taskId) || board.openThread({ taskId, kind: 'task', title: taskId, by: 'runner' });
57
+ return {
58
+ id: 'board',
59
+ specs: [boardToolSpec()],
60
+ system: 'Other members\' work is on the board (the `board` tool): read it before repeating a lookup, reply where you disagree, and ask the user only when you are stuck.',
61
+ async execute(name, input) {
62
+ if (name !== BOARD_TOOL_NAME) return json({ error: `Unknown tool: ${name}` });
63
+ const action = String(input?.action || '');
64
+ if (action === 'read') {
65
+ const text = boardText(board, { taskIds, role });
66
+ const threads = board.threads().filter((t) => t.kind !== 'proposal').map((t) => ({ id: t.id, kind: t.kind, title: t.title, status: t.status, by: t.by, posts: t.posts }));
67
+ return json({ board: text || 'Nothing on the board yet.', threads, postIds: board.posts().filter((p) => p.kind !== 'answer').map((p) => ({ id: p.id, threadId: p.threadId, by: p.by, kind: p.kind, head: p.text.slice(0, 80) })) });
68
+ }
69
+ const text = String(input?.text || '').trim();
70
+ if (action === 'post' || action === 'reply') {
71
+ if (!text) return json({ error: `${action} needs text.` });
72
+ let threadId = ownThread().id;
73
+ let replyTo = null;
74
+ if (action === 'reply') {
75
+ const target = board.postById(String(input?.postId || ''));
76
+ if (!target) return json({ error: `No post ${input?.postId}. Read the board for post ids.` });
77
+ threadId = target.threadId; replyTo = target.id;
78
+ } else if (input?.threadId && board.thread(String(input.threadId))) {
79
+ threadId = String(input.threadId);
80
+ }
81
+ const post = board.post({ threadId, by: role, kind: input?.kind || 'note', text, refs: input?.refs, replyTo });
82
+ return json({ posted: post.id, threadId, replyTo });
83
+ }
84
+ if (action === 'ask') {
85
+ if (!text) return json({ error: 'ask needs text — what you need and why.' });
86
+ if (typeof waitFor !== 'function') return json({ error: 'This run cannot wait for the user. Decide on your best assumption and say what you assumed.' });
87
+ const { thread, post } = board.ask({ taskId, by: role, type: input?.type, text, options: input?.options, timeoutMs: askTimeoutMs });
88
+ onAsk?.(thread, post);
89
+ const answer = await waitFor(thread.id, askTimeoutMs, signal);
90
+ if (!answer) return json({ answered: false, threadId: thread.id, hint: 'No answer arrived in time. Proceed on your best assumption, say what you assumed, and note that the user did not answer.' });
91
+ return json({ answered: true, threadId: thread.id, answer: answer.text, by: answer.by });
92
+ }
93
+ return json({ error: `Unknown action "${action}". Use read, post, reply or ask.` });
94
+ },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The runner's answer box: `wait(threadId, ms, signal)` resolves when `answer(threadId, …)`
100
+ * is called — by the host, from its own UI or from the run store's tail when the OTHER
101
+ * client answered. An answer that arrives before anyone waits is kept.
102
+ */
103
+ export function createAnswerBox() {
104
+ const waiting = new Map(); // threadId -> resolve
105
+ const early = new Map(); // threadId -> answer
106
+ return {
107
+ wait(threadId, timeoutMs, signal = null) {
108
+ if (early.has(threadId)) { const a = early.get(threadId); early.delete(threadId); return Promise.resolve(a); }
109
+ return new Promise((resolve) => {
110
+ let timer = null;
111
+ const done = (v) => { clearTimeout(timer); waiting.delete(threadId); signal?.removeEventListener?.('abort', onAbort); resolve(v); };
112
+ const onAbort = () => done(null);
113
+ waiting.set(threadId, done);
114
+ if (timeoutMs > 0) timer = setTimeout(() => done(null), timeoutMs);
115
+ if (typeof timer?.unref === 'function') timer.unref();
116
+ signal?.addEventListener?.('abort', onAbort, { once: true });
117
+ });
118
+ },
119
+ answer(threadId, answer) {
120
+ const a = { text: String(answer?.text ?? answer ?? ''), by: answer?.by || 'person', id: answer?.id || null };
121
+ const w = waiting.get(threadId);
122
+ if (w) w(a); else early.set(threadId, a);
123
+ return !!w;
124
+ },
125
+ get pending() { return [...waiting.keys()]; },
126
+ };
127
+ }
128
+
129
+ /** The host's toolset with the board tool added — first, so it is read first. */
130
+ export function withBoardTool(toolset, provider) {
131
+ if (!toolset) {
132
+ return {
133
+ specs: [...provider.specs], system: provider.system,
134
+ execute: (name, input) => provider.execute(name, input),
135
+ };
136
+ }
137
+ const own = new Set(provider.specs.map((s) => s.name));
138
+ return {
139
+ ...toolset,
140
+ specs: [...provider.specs, ...(toolset.specs || []).filter((s) => !own.has(s.name))],
141
+ system: [provider.system, toolset.system].filter(Boolean).join('\n\n'),
142
+ execute: (name, input, ...rest) => (own.has(name) ? provider.execute(name, input) : toolset.execute(name, input, ...rest)),
143
+ };
144
+ }
package/budget.js CHANGED
@@ -67,7 +67,7 @@ export function usageOf(u = {}) {
67
67
  export function createBudget(declared, { now = () => Date.now() } = {}) {
68
68
  const v = validateBudget(declared);
69
69
  if (!v.ok) throw new BudgetError('INVALID', v.errors.join('; '));
70
- const cap = normalizeBudget(declared);
70
+ const cap = { ...normalizeBudget(declared) };
71
71
  const startedAt = now();
72
72
  const spent = { tokens: 0, calls: 0, usd: 0 };
73
73
  const elapsed = () => now() - startedAt;
@@ -103,6 +103,12 @@ export function createBudget(declared, { now = () => Date.now() } = {}) {
103
103
  },
104
104
  remaining,
105
105
  exhausted,
106
+ /** A person raised the cap mid-run (a budget ask answered "allow"): by a factor, once. */
107
+ raise(factor = 1.5) {
108
+ const f = Math.max(1, Number(factor) || 1);
109
+ for (const k of Object.keys(cap)) if (cap[k] !== undefined) cap[k] = Math.ceil(cap[k] * f);
110
+ return { ...cap };
111
+ },
106
112
  snapshot() {
107
113
  return { cap, spent: { ...spent, ms: elapsed() }, remaining: remaining(), exhausted: exhausted() };
108
114
  },
package/index.js CHANGED
@@ -194,8 +194,9 @@ export { recipeToolProvider, recipeToolSpec, describeRecipeForApproval, RECIPE_T
194
194
  export { createBudget, validateBudget, normalizeBudget, usageOf, BudgetError, BUDGET_DIMENSIONS } from './budget.js';
195
195
  export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows, describeRole, TeamError, ROLE_MODES, MERGE_POLICIES, PLAN_MODES, GRANTABLE, STARTER_TEAMS, starterTeams, blankTeam, teamFromForm, slugTeamName } from './team.js';
196
196
  export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
197
- export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS } from './team-board.js';
198
- export { runTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
197
+ export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS, THREAD_KINDS, THREAD_STATUSES, POST_KINDS, POST_STATUSES, ASK_TYPES, emptyBoardState, foldBoard, findingsOf } from './team-board.js';
198
+ export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
199
+ export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
199
200
  export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
200
201
  export { teamLine, teamLanes } from './team-trail.js';
201
202
  export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.80.1",
3
+ "version": "0.81.1",
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",
@@ -90,6 +90,7 @@
90
90
  "./synthesis.js": "./synthesis.js",
91
91
  "./tags.js": "./tags.js",
92
92
  "./team-board.js": "./team-board.js",
93
+ "./board-tool.js": "./board-tool.js",
93
94
  "./team-plan.js": "./team-plan.js",
94
95
  "./team-run.js": "./team-run.js",
95
96
  "./team-tool.js": "./team-tool.js",
@@ -205,6 +206,7 @@
205
206
  "synthesis.js",
206
207
  "tags.js",
207
208
  "team-board.js",
209
+ "board-tool.js",
208
210
  "team-plan.js",
209
211
  "team-run.js",
210
212
  "team-tool.js",
package/team-board.js CHANGED
@@ -1,11 +1,17 @@
1
- // The board — what a team's members say to each other, as typed findings, not talk.
1
+ // The board — where a team's members meet: a message board, not a log.
2
2
  //
3
- // Members do not read each other's transcripts. A task ends with FINDINGS: claims with the
4
- // refs they came from (the brief shape, I-K1), drafts, links, questions. A later task reads
5
- // the board, sized like a shielded tool result so a long-running team does not feed a
6
- // later member forty thousand characters of earlier members. The board is the run's record:
7
- // durable, attributable per role, and after a run the thing that can become draft
8
- // briefs and be promoted on convergence (W7), rather than evaporating with the run.
3
+ // One board per run. A THREAD per task (and one per ask, discussion or proposal); POSTS in
4
+ // threads a member's findings, its notes, a question, a person's answer, a draft, a
5
+ // decision and REPLIES hanging off posts. Members do not read each other's transcripts:
6
+ // a task ends with FINDINGS (claims with the refs they came from, I-K1), those become posts
7
+ // in its thread, and a later task reads the threads it depends on, threaded and sized like a
8
+ // shielded tool result. A member that disagrees replies where it disagrees; a member that
9
+ // is stuck ASKS, and its task waits for a person — on either client — to answer. A person
10
+ // posting is a member posting, and a person's decision on any post wins.
11
+ //
12
+ // The board is the run's record: durable (every change is an event the run store folds with
13
+ // `foldBoard`), attributable per member, and — after a run — what can become draft briefs
14
+ // and be promoted on convergence (W7), rather than evaporating with the run.
9
15
  //
10
16
  // Findings are parsed GENEROUSLY from a model's answer through the structured layer, and a
11
17
  // task whose answer cannot be read as findings is not lost: its whole answer becomes one
@@ -65,46 +71,225 @@ export function parseFindings(text, { role, taskId } = {}) {
65
71
  return [stamp({ kind: 'draft', text: prose.slice(0, 2000), refs: [] }, 0)];
66
72
  }
67
73
 
68
- export function createBoard({ now = () => Date.now() } = {}) {
69
- const findings = [];
74
+ // ── threads, posts, asks ────────────────────────────────────────────────────────────────
75
+
76
+ export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposal']);
77
+ export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'approved', 'rejected']);
78
+ export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision']);
79
+ export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
80
+ export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
81
+ export const RUNNER = 'runner';
82
+ export const PERSON = 'person';
83
+ export const MAX_POST_TEXT = 4000;
84
+
85
+ const clip = (t, n) => String(t || '').trim().slice(0, n);
86
+ const refsOf = (r) => (Array.isArray(r) ? r.map(String).filter(Boolean).slice(0, 8) : []);
87
+
88
+ /** The empty folded state — what a run record holds, what `foldBoard` grows. */
89
+ export function emptyBoardState() { return { threads: [], posts: [] }; }
90
+
91
+ /**
92
+ * Fold one board event into a state (pure; the gateway's run store and both clients use it).
93
+ * Ids dedupe: an answer posted through the gateway and echoed by the running client's runner
94
+ * arrives twice with one id and lands once.
95
+ */
96
+ export function foldBoard(state, ev) {
97
+ const s = state && Array.isArray(state.threads) && Array.isArray(state.posts) ? state : emptyBoardState();
98
+ const type = String(ev?.type || '');
99
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : ev || {};
100
+ if (type === 'board.thread' && p.thread?.id) {
101
+ if (!s.threads.some((t) => t.id === p.thread.id)) s.threads.push({ ...p.thread });
102
+ } else if (type === 'board.post' && p.post?.id) {
103
+ if (!s.posts.some((x) => x.id === p.post.id)) s.posts.push({ ...p.post });
104
+ const t = s.threads.find((x) => x.id === p.post.threadId);
105
+ if (t) { t.lastAt = p.post.at; t.lastBy = p.post.by; t.posts = (t.posts || 0) + 1; }
106
+ } else if (type === 'board.decision' && p.postId) {
107
+ const x = s.posts.find((q) => q.id === p.postId);
108
+ if (x) { x.status = p.status; x.decidedBy = p.by; x.decidedAt = p.at ?? ev?.at; }
109
+ } else if (type === 'board.thread-status' && p.threadId) {
110
+ const t = s.threads.find((x) => x.id === p.threadId);
111
+ if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; }
112
+ }
113
+ return s;
114
+ }
115
+
116
+ /**
117
+ * The live board a run works on. `state` seeds it (a resume); `onEvent` receives every
118
+ * change as the event the run store folds. The legacy findings API (`add`, `all`, `byTask`)
119
+ * stays: a finding is a post of kind `finding` in its task's thread.
120
+ */
121
+ export function createBoard({ now = () => Date.now(), newId = null, state = null, onEvent = null } = {}) {
122
+ const st = state && Array.isArray(state.threads) ? { threads: state.threads.map((t) => ({ ...t })), posts: (state.posts || []).map((x) => ({ ...x })) } : emptyBoardState();
70
123
  const listeners = new Set();
71
- return {
72
- add(list) {
73
- const at = now();
124
+ let seq = st.posts.length + st.threads.length;
125
+ const mk = (prefix) => (newId ? newId(prefix) : `${prefix}_${(++seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`);
126
+ const say = (type, payload) => { const ev = { type, at: now(), ...payload }; if (onEvent) onEvent(type, ev); return ev; };
127
+ const threadOf = (id) => st.threads.find((t) => t.id === id);
128
+ const postOf = (id) => st.posts.find((x) => x.id === id);
129
+ const threadForTask = (taskId) => st.threads.find((t) => t.kind === 'task' && t.taskId === taskId);
130
+
131
+ const api = {
132
+ /** Open a thread. A task's thread is opened once; asking for it again returns it. */
133
+ openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null } = {}) {
134
+ if (kind === 'task' && taskId) { const had = threadForTask(taskId); if (had) return had; }
135
+ 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 } : {}) };
136
+ st.threads.push(thread);
137
+ say('board.thread', { thread: { ...thread } });
138
+ return thread;
139
+ },
140
+ /** A post in a thread; `replyTo` makes it a reply. */
141
+ post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null } = {}) {
142
+ const t = threadOf(threadId);
143
+ if (!t) throw new Error(`no thread ${threadId}`);
144
+ if (id && postOf(id)) return postOf(id);
145
+ const post = {
146
+ id: id || mk('p'), threadId, by: String(by || RUNNER), kind: POST_KINDS.includes(kind) ? kind : 'note',
147
+ text: clip(text, MAX_POST_TEXT), refs: refsOf(refs), replyTo, status: POST_STATUSES.includes(status) ? status : 'open', at: now(),
148
+ ...(finding ? { finding } : {}), ...(ask ? { ask } : {}),
149
+ };
150
+ st.posts.push(post);
151
+ t.lastAt = post.at; t.lastBy = post.by; t.posts = (t.posts || 0) + 1;
152
+ say('board.post', { post: { ...post } });
153
+ for (const l of listeners) l(post);
154
+ return post;
155
+ },
156
+ /** A reply hangs off a post and lives in that post's thread. */
157
+ reply({ postId, ...args }) {
158
+ const target = postOf(postId || args.replyTo);
159
+ if (!target) throw new Error(`no post ${postId}`);
160
+ return api.post({ ...args, threadId: target.threadId, replyTo: target.id });
161
+ },
162
+ /** A person's (or the judge's) decision on a post. */
163
+ decide(postId, status, by = PERSON) {
164
+ const x = postOf(postId);
165
+ if (!x || !['approved', 'rejected', 'proposed', 'open'].includes(status)) return null;
166
+ x.status = status; x.decidedBy = by; x.decidedAt = now();
167
+ say('board.decision', { postId, status, by, at: x.decidedAt });
168
+ return x;
169
+ },
170
+ setThreadStatus(threadId, status, extra = {}) {
171
+ const t = threadOf(threadId);
172
+ if (!t || !THREAD_STATUSES.includes(status)) return null;
173
+ t.status = status; if (status !== 'waiting') t.waitingOn = null; Object.assign(t, extra);
174
+ say('board.thread-status', { threadId, status, ...extra });
175
+ return t;
176
+ },
177
+ /**
178
+ * A member is stuck: open an ask thread (status `waiting`) with the question as its
179
+ * first post. The runner waits on it; a person answers from either client.
180
+ */
181
+ ask({ taskId = null, by, type = 'info', text, options = [], timeoutMs = 0, title = '' } = {}) {
182
+ const ask = { type: ASK_TYPES.includes(type) ? type : 'info', options: (Array.isArray(options) ? options : []).map(String).slice(0, 6), timeoutMs };
183
+ const thread = api.openThread({ taskId, kind: 'ask', title: title || clip(text, 120), by, status: 'waiting', ask });
184
+ thread.waitingOn = PERSON;
185
+ const post = api.post({ threadId: thread.id, by, kind: 'question', text, ask });
186
+ return { thread, post };
187
+ },
188
+ /** The answer to an ask — from a person, on any client. Resolves the thread. */
189
+ answer(threadId, { id = null, text, by = PERSON } = {}) {
190
+ const t = threadOf(threadId);
191
+ if (!t) return null;
192
+ const post = api.post({ id, threadId, by, kind: 'answer', text });
193
+ api.setThreadStatus(threadId, 'resolved', { answeredAt: post.at });
194
+ return post;
195
+ },
196
+ thread: threadOf,
197
+ threadForTask,
198
+ threads: () => st.threads.map((t) => ({ ...t })),
199
+ posts: (threadId = null) => st.posts.filter((x) => !threadId || x.threadId === threadId).map((x) => ({ ...x })),
200
+ postById: postOf,
201
+ /** Every ask still waiting — what a client pins at the top. */
202
+ waiting: () => st.threads.filter((t) => t.kind === 'ask' && t.status === 'waiting').map((t) => ({ ...t })),
203
+ /** The whole board, for the run record and for a resume. */
204
+ state: () => ({ threads: st.threads.map((t) => ({ ...t })), posts: st.posts.map((x) => ({ ...x })) }),
205
+
206
+ // ── findings, as before: a finding is a post in its task's thread ──
207
+ add(list, { by = null } = {}) {
208
+ let n = 0;
74
209
  for (const f of Array.isArray(list) ? list : [list]) {
75
210
  if (!f || !f.text) continue;
76
- const entry = { ...f, at };
77
- findings.push(entry);
78
- for (const l of listeners) l(entry);
211
+ 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' }));
212
+ 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 } });
213
+ n += 1;
79
214
  }
80
- return findings.length;
215
+ return n;
81
216
  },
82
- all: () => [...findings],
83
- byTask: (taskId) => findings.filter((f) => f.taskId === taskId),
84
- byRole: (role) => findings.filter((f) => f.role === role),
85
- onFinding(fn) { listeners.add(fn); return () => listeners.delete(fn); },
86
- get size() { return findings.length; },
217
+ all: () => st.posts.filter((x) => x.kind === 'finding').map(asFinding(st)),
218
+ byTask: (taskId) => api.all().filter((f) => f.taskId === taskId),
219
+ byRole: (role) => api.all().filter((f) => f.role === role),
220
+ onFinding(fn) { const l = (p) => { if (p.kind === 'finding') fn(asFinding(st)(p)); }; listeners.add(l); return () => listeners.delete(l); },
221
+ get size() { return st.posts.filter((x) => x.kind === 'finding').length; },
87
222
  };
223
+ return api;
224
+ }
225
+
226
+ /** A finding post in the legacy finding shape (what merge, briefs and lanes read). */
227
+ function asFinding(st) {
228
+ return (p) => {
229
+ const t = st.threads.find((x) => x.id === p.threadId);
230
+ 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 };
231
+ };
232
+ }
233
+
234
+ /** Findings from a folded state (a run record read from the store). */
235
+ export function findingsOf(state) {
236
+ const st = state && Array.isArray(state.posts) ? state : emptyBoardState();
237
+ return st.posts.filter((x) => x.kind === 'finding').map(asFinding(st));
88
238
  }
89
239
 
90
240
  /**
91
241
  * What a later task READS: the findings of the tasks it depends on (or everything so far),
92
242
  * sized. Newest are kept whole; the oldest are what get cut, and the cut is stated.
93
243
  */
94
- export function boardText(findings, { taskIds = null, max = BOARD_TEXT_MAX } = {}) {
95
- const list = (findings || []).filter((f) => !taskIds || taskIds.includes(f.taskId));
96
- if (!list.length) return '';
97
- const lines = list.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(', ')})` : ''}`);
98
- let out = lines.join('\n');
99
- if (out.length <= max) return `Findings so far:\n${out}`;
100
- let kept = [];
244
+ export function boardText(source, { taskIds = null, max = BOARD_TEXT_MAX, role = null } = {}) {
245
+ // A board (threads + posts) reads threaded; a bare findings array reads as before.
246
+ const state = source && typeof source.state === 'function' ? source.state() : (source && Array.isArray(source.posts) ? source : null);
247
+ const lines = state ? threadedLines(state, { taskIds, role }) : findingLines(source, taskIds);
248
+ if (!lines.length) return '';
249
+ const head = state ? 'The board so far' : 'Findings so far';
250
+ const out = lines.join('\n');
251
+ if (out.length <= max) return `${head}:\n${out}`;
252
+ const kept = [];
101
253
  let size = 0;
102
254
  for (let i = lines.length - 1; i >= 0; i -= 1) {
103
255
  if (size + lines[i].length + 1 > max) break;
104
256
  kept.unshift(lines[i]);
105
257
  size += lines[i].length + 1;
106
258
  }
107
- return `Findings so far (${lines.length - kept.length} earlier ones omitted for length):\n${kept.join('\n')}`;
259
+ return `${head} (${lines.length - kept.length} earlier lines omitted for length):\n${kept.join('\n')}`;
260
+ }
261
+
262
+ function findingLines(findings, taskIds) {
263
+ return (findings || []).filter((f) => !taskIds || taskIds.includes(f.taskId))
264
+ .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(', ')})` : ''}`);
265
+ }
266
+
267
+ /**
268
+ * What a member READS: the task threads it depends on (or every task thread), the answered
269
+ * asks and settled discussions — never rejected posts — threaded, a reply under its post.
270
+ * A person's decision is marked so a member treats it as settled.
271
+ */
272
+ function threadedLines(state, { taskIds, role }) {
273
+ const out = [];
274
+ const posts = state.posts;
275
+ const threads = state.threads.filter((t) => {
276
+ if (t.kind === 'task') return !taskIds || taskIds.includes(t.taskId);
277
+ if (t.kind === 'ask') return t.status === 'resolved' && (!role || t.by === role || t.by === RUNNER);
278
+ if (t.kind === 'discussion') return true;
279
+ return false; // a proposal is the run's output, not a member's input
280
+ });
281
+ for (const t of threads) {
282
+ const own = posts.filter((x) => x.threadId === t.id && x.status !== 'rejected');
283
+ if (!own.length) continue;
284
+ out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
285
+ const line = (x, depth) => {
286
+ 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(' · ');
287
+ out.push(`${' '.repeat(depth)}- [${tag}] ${x.text}${x.refs?.length ? ` (refs: ${x.refs.join(', ')})` : ''}`);
288
+ for (const r of own.filter((y) => y.replyTo === x.id)) line(r, depth + 1);
289
+ };
290
+ for (const x of own.filter((y) => !y.replyTo)) line(x, 0);
291
+ }
292
+ return out;
108
293
  }
109
294
 
110
295
  /** Findings → the claim shape a draft brief takes (W7): text + refs as `{ kind, id }`. */
package/team-run.js CHANGED
@@ -19,10 +19,11 @@
19
19
  import { normalizeTeam } from './team.js';
20
20
  import { createBudget } from './budget.js';
21
21
  import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
22
- import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims } from './team-board.js';
22
+ import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
23
+ import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
23
24
  import { converge } from './promotion.js';
24
25
 
25
- export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
26
+ export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
26
27
  const DEFAULT_CONCURRENCY = 3;
27
28
 
28
29
  export class TeamRunError extends Error {
@@ -88,15 +89,29 @@ export async function runTeam({
88
89
  team, request, callModel, toolsFor = () => undefined, appoint = null, runRecipe = null,
89
90
  now = () => Date.now(), newId = () => `run_${Math.random().toString(36).slice(2, 10)}`,
90
91
  emit = () => {}, signal = null, maxConcurrency = DEFAULT_CONCURRENCY, runId = null,
92
+ // Asks: the box a person's answers arrive in (the host feeds it from its UI and from the
93
+ // run store's tail), and how long a member waits before the run checkpoints. 0 = a member
94
+ // that asks is told to proceed on its own assumption at once.
95
+ answers = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS,
96
+ // A checkpoint from a run that ended `waiting` — see resumeTeam.
97
+ resume = null,
91
98
  } = {}) {
92
99
  if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
93
100
  const t = normalizeTeam(team); // throws on a team without a budget — O1
94
- const id = runId || newId();
95
- const budget = createBudget(t.budget, { now });
96
- const board = createBoard({ now });
97
- const startedAt = now();
98
- const tasksOut = [];
101
+ const id = runId || resume?.runId || newId();
102
+ const budget = createBudget(resume?.budget?.cap || t.budget, { now });
103
+ if (resume?.budget?.spent) budget.charge({ tokens: resume.budget.spent.tokens, calls: resume.budget.spent.calls, usd: resume.budget.spent.usd });
99
104
  const say = (type, payload = {}) => emit(type, { runId: id, at: now(), ...payload });
105
+ // Every change to the board is an event the run store folds — the other client reads it live.
106
+ const board = createBoard({ now, state: resume?.board || null, onEvent: (type, ev) => say(type, ev) });
107
+ // No answer box from the host means nobody can answer: asks are off, a member that asks is
108
+ // told to proceed on its own assumption at once, and the budget stop is final.
109
+ const box = answers || createAnswerBox();
110
+ const askMs = answers ? askTimeoutMs : 0;
111
+ const startedAt = resume?.startedAt || now();
112
+ // Tasks a checkpoint already finished are carried over, not re-run.
113
+ const tasksOut = (resume?.tasks || []).filter((x) => x.status === 'ok').map((x) => ({ ...x }));
114
+ const carried = new Set(tasksOut.map((x) => x.id));
100
115
  const roleOf = (rid) => t.roles.find((r) => r.id === rid);
101
116
  // `exclude` holds what failed as unavailable this run; a re-appointment skips it. A role's
102
117
  // pinned model is tried first and, when it is the one that failed, the roster steps in.
@@ -107,12 +122,12 @@ export async function runTeam({
107
122
  const MAX_APPOINTMENTS = 3;
108
123
  const stopped = () => !!signal?.aborted;
109
124
 
110
- say('run.started', { team: t.name, request: String(request || ''), budget: t.budget, roles: t.roles.map((r) => r.id) });
125
+ say(resume ? 'run.resumed' : 'run.started', { team: t.name, request: String(request || ''), budget: budget.cap, roles: t.roles.map((r) => r.id), ...(resume ? { carried: [...carried] } : {}) });
111
126
 
112
127
  // ── plan ──────────────────────────────────────────────────────────────────────────────
113
- let tasks;
114
- let planBy = 'fixed';
115
- if (t.plan === 'planner') {
128
+ let tasks = resume?.plan?.tasks?.length ? resume.plan.tasks : null;
129
+ let planBy = resume?.plan?.by || 'fixed';
130
+ if (!tasks && t.plan === 'planner') {
116
131
  const planner = strongestRole(t);
117
132
  const m = modelFor(planner);
118
133
  if (m && budget.canAfford({ tokens: 0 })) {
@@ -124,20 +139,37 @@ export async function runTeam({
124
139
  }
125
140
  if (!tasks) tasks = fixedPlan(t, request);
126
141
  say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn })) });
142
+ // A thread per task, before anything runs: a member's findings and replies have a home.
143
+ for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: RUNNER });
127
144
 
128
145
  const finish = (status, extra = {}) => {
129
146
  const usage = budget.snapshot();
130
- const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), usage, startedAt, endedAt: now(), ...extra };
131
- say('run.done', { status, usage, proposal: out.proposal || null, failedTaskIds: tasksOut.filter((x) => x.status === 'failed').map((x) => x.id) });
147
+ const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), threads: board.state(), usage, startedAt, endedAt: now(), ...extra };
148
+ // What a resume needs, on the record: the plan, what finished, the board, the spend.
149
+ if (status === 'waiting') out.checkpoint = { runId: id, startedAt, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.state(), budget: { cap: budget.cap, spent: usage.spent } };
150
+ say('run.done', { status, usage, proposal: out.proposal || null, failedTaskIds: tasksOut.filter((x) => x.status === 'failed').map((x) => x.id), waitingTaskIds: tasksOut.filter((x) => x.status === 'waiting').map((x) => x.id), ...(out.checkpoint ? { checkpoint: out.checkpoint } : {}) });
132
151
  return out;
133
152
  };
134
153
 
154
+ /**
155
+ * The runner asks the person (a budget, a direction). Same thread shape as a member's ask;
156
+ * answered from either client; null when nobody answered in time or asks are off.
157
+ */
158
+ const askPerson = async ({ type, text, options, taskId = null }) => {
159
+ if (askMs <= 0) return null;
160
+ const { thread } = board.ask({ taskId, by: RUNNER, type, text, options, timeoutMs: askMs });
161
+ say('run.waiting', { threadId: thread.id, askType: type, text, options });
162
+ return box.wait(thread.id, askMs, signal);
163
+ };
164
+ let waitingOnPerson = false; // a task that timed out on its ask — the run checkpoints
165
+
135
166
  // ── fan out, in waves ─────────────────────────────────────────────────────────────────
136
167
  let overBudget = false;
137
- for (const wave of waves(tasks)) {
138
- if (stopped() || overBudget) break;
168
+ let budgetAsked = !!resume?.budgetAsked;
169
+ for (const wave of waves(tasks.filter((x) => !carried.has(x.id)))) {
170
+ if (stopped() || overBudget || waitingOnPerson) break;
139
171
  await pool(wave, maxConcurrency, async (task) => {
140
- if (stopped() || overBudget) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
172
+ if (stopped() || overBudget || waitingOnPerson) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
141
173
  const role = roleOf(task.role);
142
174
  const t0 = now();
143
175
  say('task.started', { taskId: task.id, role: task.role, title: task.title });
@@ -145,6 +177,11 @@ export async function runTeam({
145
177
  let usage = null;
146
178
  let status = 'ok';
147
179
  let error = null;
180
+ // The task's own abort: an ask nobody answered in time stops THIS member's turn (the
181
+ // run then checkpoints), without stopping the run's other members.
182
+ const taskAc = new AbortController();
183
+ signal?.addEventListener?.('abort', () => taskAc.abort(), { once: true });
184
+ let askedAndWaiting = null;
148
185
  try {
149
186
  if (role.mode === 'recipe') {
150
187
  if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
@@ -154,10 +191,22 @@ export async function runTeam({
154
191
  // A call's tokens are unknown until it returns; what can be asked beforehand is
155
192
  // whether the budget is already exhausted and whether one more call is allowed.
156
193
  if (!budget.canAfford({ tokens: 0 })) { overBudget = true; throw new Error('over budget'); }
157
- const prior = boardText(board.all(), { taskIds: task.dependsOn?.length ? task.dependsOn : null });
194
+ // What this member reads: the threads of the tasks it depends on, answered asks (its
195
+ // own — a resumed task finds the person's answer here), settled discussions.
196
+ const prior = boardText(board, { taskIds: task.dependsOn?.length ? task.dependsOn : null, role: role.id });
158
197
  const prompt = [task.prompt, prior, findingsInstruction()].filter(Boolean).join('\n\n');
159
198
  // A host may build a toolset asynchronously (connecting MCP servers takes time).
160
- const tools = await toolsFor(role);
199
+ // The board tool rides on top of whatever the role was granted.
200
+ const boardTool = boardToolProvider({
201
+ board, role: role.id, taskId: task.id, taskIds: task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
202
+ onAsk: (thread) => { say('task.waiting', { taskId: task.id, role: role.id, threadId: thread.id, text: thread.title }); },
203
+ waitFor: askMs > 0 ? async (threadId, ms, sig) => {
204
+ const a = await box.wait(threadId, ms, sig);
205
+ if (!a && !stopped()) { askedAndWaiting = threadId; taskAc.abort(); }
206
+ return a;
207
+ } : null,
208
+ });
209
+ const tools = withBoardTool(await toolsFor(role), boardTool);
161
210
  // A model that is not there — not deployed, no key, gone — is not the task failing:
162
211
  // the next model on the roster is appointed and the task tried again, up to three
163
212
  // models. Anything else (a refusal, a timeout, a bad request) fails the task.
@@ -168,12 +217,13 @@ export async function runTeam({
168
217
  if (attempt > 1) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude] });
169
218
  const res = await callModel({
170
219
  runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
171
- system: role.prompt, prompt, tools, signal,
220
+ system: role.prompt, prompt, tools, signal: taskAc.signal,
172
221
  onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
173
222
  });
174
223
  usage = res?.usage || null;
175
224
  if (usage) budget.charge(usage);
176
- if (res?.aborted) { status = 'stopped'; break; }
225
+ if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; break; }
226
+ if (res?.aborted || taskAc.signal.aborted) { status = 'stopped'; break; }
177
227
  // A turn that ended with nothing to say — an agent that exited, a stream that
178
228
  // died after its tool calls — is not a done task: three members "completed" empty
179
229
  // once, the run merged nothing, and the caller ran the team again. It is treated
@@ -185,21 +235,34 @@ export async function runTeam({
185
235
  }
186
236
  }
187
237
  } catch (e) {
188
- status = overBudget ? 'over-budget' : 'failed';
189
- error = String(e?.message || e);
238
+ if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; }
239
+ else { status = overBudget ? 'over-budget' : 'failed'; error = String(e?.message || e); }
190
240
  }
191
241
  const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
192
242
  if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
193
- const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings };
243
+ const thread = board.threadForTask(task.id);
244
+ if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
245
+ const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
194
246
  tasksOut.push(row);
195
- say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length });
247
+ say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
196
248
  if (budget.exhausted()) overBudget = true;
197
249
  });
250
+ // Over budget with work left: ask the person ONCE for more, on the board, before stopping.
251
+ if (overBudget && !budgetAsked && !stopped()) {
252
+ budgetAsked = true;
253
+ const left = tasks.filter((x) => !tasksOut.some((y) => y.id === x.id)).length;
254
+ const spent = budget.snapshot().spent;
255
+ const a = await askPerson({ type: 'budget', text: `The team has used its budget (${Object.entries(spent).filter(([k]) => budget.cap[k] !== undefined).map(([k, v]) => `${k} ${v} of ${budget.cap[k]}`).join(', ')}) with ${left} task${left === 1 ? '' : 's'} left. Raise it by half, or stop here with what it has?`, options: ['Raise by half', 'Stop here'] });
256
+ if (a && /raise|allow|yes|more|continue/i.test(a.text)) { budget.raise(1.5); overBudget = false; }
257
+ }
198
258
  }
199
259
  // Every planned task gets a row — what never ran is recorded as skipped, not forgotten.
200
260
  for (const task of tasks) if (!tasksOut.some((x) => x.id === task.id)) tasksOut.push({ id: task.id, role: task.role, title: task.title, status: 'skipped', text: '', findings: [] });
201
261
  if (stopped()) return finish('stopped');
202
- if (overBudget) return finish('over-budget', { proposal: mergeCheap(t, board.all(), tasksOut) });
262
+ if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
263
+ // Over budget is a STOP only when it left work undone; a budget spent on the last task
264
+ // is a run that finished, and the merge below falls back to the cheap one if it must.
265
+ if (overBudget && tasksOut.some((x) => x.status === 'skipped' || x.status === 'over-budget')) return finish('over-budget', { proposal: mergeCheap(t, board.all(), tasksOut) });
203
266
 
204
267
  // ── merge ─────────────────────────────────────────────────────────────────────────────
205
268
  say('run.merging', { policy: t.merge });
@@ -213,7 +276,7 @@ export async function runTeam({
213
276
  const prompt = [
214
277
  `You are the ${judge.name || judge.id} of team "${t.name}". Review the team's findings for the request below and write the final answer — accurate, concise, and only what the findings support. Flag anything the members disagreed on.`,
215
278
  `Request: ${String(request || '').trim()}`,
216
- boardText(board.all()),
279
+ boardText(board),
217
280
  ].join('\n\n');
218
281
  const res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: m.model, mode: 'model', system: judge.prompt, prompt, tools: undefined, signal });
219
282
  if (res?.usage) budget.charge(res.usage);
@@ -230,10 +293,23 @@ export async function runTeam({
230
293
  } else {
231
294
  proposal = mergeCheap(t, board.all(), tasksOut);
232
295
  }
296
+ // The merge is a proposal thread: a draft the person approves, rejects or replies to.
297
+ if (proposal?.text || proposal?.agreed) {
298
+ const th = board.openThread({ kind: 'proposal', title: `Proposal — ${t.merge}`, by: proposal.by || RUNNER });
299
+ board.post({ threadId: th.id, by: proposal.by || RUNNER, kind: 'draft', status: 'proposed', text: proposal.text || (proposal.agreed || []).map((c) => c.text).join('\n'), refs: [] });
300
+ }
233
301
  const failed = tasksOut.some((x) => x.status !== 'ok');
234
302
  return finish(failed ? 'partial' : 'completed', { proposal });
235
303
  }
236
304
 
305
+ /** Continue a run that ended `waiting` from its checkpoint — the answered ask is on the board. */
306
+ export function resumeTeam({ checkpoint, ...deps } = {}) {
307
+ if (!checkpoint?.plan?.tasks) throw new TeamRunError('BAD_RESUME', 'a checkpoint with a plan is required');
308
+ // The waiting task runs again; its ask thread now holds the answer, and boardText gives it
309
+ // to the member. Tasks recorded `waiting`/`skipped` are dropped from the carried list.
310
+ return runTeam({ ...deps, resume: checkpoint });
311
+ }
312
+
237
313
  /** No model: the members' work side by side, findings first — always available. */
238
314
  function mergeCheap(team, findings, tasks) {
239
315
  const sections = tasks.filter((x) => x.status === 'ok').map((x) => {
package/team-tool.js CHANGED
@@ -116,9 +116,12 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
116
116
  if (action === 'run') {
117
117
  if (!request) return json({ error: 'run needs a request — what should the team do?' });
118
118
  if (typeof run !== 'function') return json({ error: 'This surface cannot run a team.' });
119
- const key = `${team.name}\n${request}`;
119
+ // ONE run per team per turn — keyed by the team, not the request: a model that
120
+ // re-runs after a partial result rephrases the request each time, and that was a
121
+ // second circle. Its partial result stands; the person decides what happens next.
122
+ const key = team.name;
120
123
  const prior = ran.get(key);
121
- if (prior) return json({ error: `The "${team.name}" team already ran this request in this turn (run ${prior.runId}, ${prior.status}). Do not run it again: tell the user what happened — ${prior.summary} — and ask how to proceed.`, runId: prior.runId, status: prior.status, tasks: prior.tasks });
124
+ if (prior) return json({ error: `The "${team.name}" team already ran in this turn (run ${prior.runId}, ${prior.status}). Do not run it again, even with a different request: report what it produced — ${prior.summary} — with its proposal, and ask the user how to proceed.`, runId: prior.runId, status: prior.status, tasks: prior.tasks, proposal: prior.proposal });
122
125
  const dry = dryRunTeam(team, request, { appoint });
123
126
  if (!dry.ok) return json({ error: `No model is available for role(s): ${dry.missing.join(', ')}.`, roles: dry.roles });
124
127
  const result = await run({ team, request, toolset: bound });
@@ -126,10 +129,10 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
126
129
  const tasks = (result.tasks || []).map((x) => ({ id: x.id, role: x.role, status: x.status, ms: x.ms, findings: (x.findings || []).length, error: x.error || undefined }));
127
130
  const failed = tasks.filter((x) => x.status !== 'ok');
128
131
  const summary = failed.length ? failed.map((x) => `${x.role} ${x.status}${x.error ? ` (${x.error})` : ''}`).join('; ') : `${findings.length} findings`;
129
- ran.set(key, { runId: result.runId, status: result.status, summary, tasks });
132
+ ran.set(key, { runId: result.runId, status: result.status, summary, tasks, proposal: result.proposal });
130
133
  const hint = result.status === 'over-budget' ? 'The team stopped at its budget; the proposal is what it had. Say so.'
131
134
  : result.status === 'failed' ? `The run FAILED — ${summary}. Do not run the team again this turn. Tell the user exactly which role failed and why, and ask whether to retry, change the team\'s models in Settings → Teams, or answer without the team.`
132
- : failed.length ? `Some roles did not finish — ${summary}. Say so alongside the proposal.` : undefined;
135
+ : failed.length ? `Some roles did not finish — ${summary}. Present the proposal as the team's answer and say which role did not finish; do NOT run the team again this turn.` : undefined;
133
136
  return json({ name: team.name, runId: result.runId, status: result.status, proposal: result.proposal, tasks, findings, usage: result.usage, hint });
134
137
  }
135
138
  return json({ error: `Unknown action "${action}". Use run, dry_run or save.` });
package/team-trail.js CHANGED
@@ -10,6 +10,11 @@ export function teamLine(ev) {
10
10
  case 'run.started': return { type: 'status', text: `team ${ev.team}: ${(ev.roles || []).join(', ')}` };
11
11
  case 'plan.ready': return { type: 'status', text: `plan: ${(ev.tasks || []).length} task${(ev.tasks || []).length === 1 ? '' : 's'} (${ev.by})` };
12
12
  case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}` };
13
+ case 'task.waiting': return { type: 'status', text: `${role} is waiting on you — ${ev.text || 'a question on the board'}` };
14
+ case 'run.waiting': return { type: 'status', text: `waiting on you — ${ev.text || ev.type || 'a question on the board'}` };
15
+ case 'run.resumed': return { type: 'status', text: `team ${ev.team} resumed${(ev.carried || []).length ? ` (${ev.carried.length} task${ev.carried.length === 1 ? '' : 's'} carried over)` : ''}` };
16
+ case 'board.post': return ev.post && ev.post.kind !== 'finding' ? { type: 'status', text: `${ev.post.by} ${ev.post.replyTo ? 'replied' : 'posted'} (${ev.post.kind}): ${String(ev.post.text || '').slice(0, 120)}` } : null;
17
+ case 'board.decision': return { type: 'status', text: `${ev.by || 'someone'} ${ev.status} a post` };
13
18
  case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable)` };
14
19
  case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
15
20
  case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
@@ -30,7 +35,10 @@ export function teamLanes(prev, ev) {
30
35
  case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
31
36
  case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
32
37
  case 'task.finding': lanes.findings += 1; if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], findings: (lanes.tasks[ev.taskId].findings || 0) + 1 }; break;
38
+ case 'task.waiting': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: 'waiting', waitingOn: ev.threadId }; lanes.waiting = [...(lanes.waiting || []), ev.threadId]; break;
33
39
  case 'task.done': case 'task.failed': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: ev.status || 'ok', ms: ev.ms }; break;
40
+ case 'board.thread-status': if (ev.status !== 'waiting' && lanes.waiting) lanes.waiting = lanes.waiting.filter((x) => x !== ev.threadId); break;
41
+ case 'run.waiting': lanes.waiting = [...(lanes.waiting || []), ev.threadId]; break;
34
42
  case 'run.done': lanes.status = ev.status; lanes.usage = ev.usage; break;
35
43
  default: break;
36
44
  }