@chatpanel/events 0.90.0 → 0.91.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 CHANGED
@@ -6,10 +6,15 @@
6
6
  // person to answer it, from either client. Members do not chat freely: a post is typed, a
7
7
  // reply hangs off a post, and an ask pauses the member's own task, never the run.
8
8
  //
9
+ // A fifth, `request`: a member hands a PIECE of its task to someone else — the runner turns
10
+ // it into a sub-task with its own thread, offers it to the members that fit, posts it on the
11
+ // job board when none does, and (by default) waits for it and returns its findings here.
12
+ //
9
13
  // Bound per task: the member's role and task are fixed at bind time, so a member cannot post
10
14
  // as someone else, and its ask lands in its own task's context.
11
15
 
12
16
  import { boardText, POST_KINDS, ASK_TYPES } from './team-board.js';
17
+ import { normalizeRequest } from './team-subtask.js';
13
18
 
14
19
  export const BOARD_TOOL_NAME = 'board';
15
20
  export const DEFAULT_ASK_TIMEOUT_MS = 10 * 60_000;
@@ -24,11 +29,12 @@ export function boardToolSpec() {
24
29
  + '{"action":"post","kind":"note|draft|question","text":"…","refs":["…"]} a post in your task\'s thread; '
25
30
  + '{"action":"reply","postId":"…","kind":"note","text":"…","refs":["…"]} a reply to another member\'s post — agree, dispute with a ref, extend; '
26
31
  + '{"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.',
32
+ + '{"action":"request","title":"check the valuation","brief":"what to do and what done looks like","skills":["finance"],"grants":["web"],"wait":true} hands a piece of your task to another member (or, if none fits, to the job board): a sub-task with its own thread; with wait (default) you get its findings back here. '
33
+ + '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. Request only work you should not do yourself — another skill, a tool you lack, or a second pair of eyes.',
28
34
  parameters: {
29
35
  type: 'object',
30
36
  properties: {
31
- action: { type: 'string', enum: ['read', 'post', 'reply', 'ask'] },
37
+ action: { type: 'string', enum: ['read', 'post', 'reply', 'ask', 'request'] },
32
38
  kind: { type: 'string', enum: POST_KINDS.filter((k) => k !== 'finding' && k !== 'answer' && k !== 'decision') },
33
39
  text: { type: 'string' },
34
40
  refs: { type: 'array', items: { type: 'string' } },
@@ -36,6 +42,11 @@ export function boardToolSpec() {
36
42
  threadId: { type: 'string', description: 'For post: another thread you may see (default: your task\'s).' },
37
43
  type: { type: 'string', enum: [...ASK_TYPES], description: 'For ask.' },
38
44
  options: { type: 'array', items: { type: 'string' }, description: 'For ask: choices to offer the user.' },
45
+ title: { type: 'string', description: 'For request: the sub-task in a few words.' },
46
+ brief: { type: 'string', description: 'For request: what to do and what done looks like.' },
47
+ skills: { type: 'array', items: { type: 'string' }, description: 'For request: skills it needs.' },
48
+ grants: { type: 'array', items: { type: 'string' }, description: 'For request: tools it needs — data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr.' },
49
+ wait: { type: 'boolean', description: 'For request: wait for it and get its findings back (default true); false queues it after your task.' },
39
50
  },
40
51
  required: ['action'],
41
52
  },
@@ -51,8 +62,10 @@ const json = (v) => JSON.stringify(v);
51
62
  * @param taskIds the tasks this member may read (its dependencies, or null for all)
52
63
  * @param waitFor `async (threadId, timeoutMs, signal) => { text, by } | null` — the runner's answer box
53
64
  * @param onAsk `(thread, post) => void` — the runner marks the task waiting
65
+ * @param onRequest `async ({ title, brief, needs, wait }) => result` — the runner turns a request
66
+ * into a sub-task (team-subtask.js); absent, a member is told to do it itself
54
67
  */
55
- export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
68
+ export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor = null, onAsk = null, onRequest = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS, signal = null } = {}) {
56
69
  const ownThread = () => board.threadForTask(taskId) || board.openThread({ taskId, kind: 'task', title: taskId, by: 'runner' });
57
70
  return {
58
71
  id: 'board',
@@ -90,7 +103,16 @@ export function boardToolProvider({ board, role, taskId, taskIds = null, waitFor
90
103
  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
104
  return json({ answered: true, threadId: thread.id, answer: answer.text, by: answer.by });
92
105
  }
93
- return json({ error: `Unknown action "${action}". Use read, post, reply or ask.` });
106
+ if (action === 'request') {
107
+ if (typeof onRequest !== 'function') return json({ error: 'This run cannot delegate. Do the work yourself with the tools you have, and say what you could not do.' });
108
+ const r = normalizeRequest(input);
109
+ if (!r.ok) return json({ error: r.error });
110
+ // The request is a post in the member's own thread — the record of who asked for what.
111
+ const post = board.post({ threadId: ownThread().id, by: role, kind: 'request', text: `${r.request.title}${r.request.brief && r.request.brief !== r.request.title ? ` — ${r.request.brief}` : ''}`, refs: [] });
112
+ const result = await onRequest({ ...r.request, postId: post.id });
113
+ return json(result || { error: 'the request was not taken' });
114
+ }
115
+ return json({ error: `Unknown action "${action}". Use read, post, reply, ask or request.` });
94
116
  },
95
117
  };
96
118
  }
package/index.js CHANGED
@@ -70,7 +70,7 @@ export {
70
70
  WEATHER_HOST, WEATHER_TIMEOUT_MS, WeatherError,
71
71
  } from './weather.js';
72
72
  export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
73
- export { toolNeedFor } from './tool-need.js';
73
+ export { toolNeedFor, grantsNeededFor } from './tool-need.js';
74
74
  export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart.js';
75
75
  export { validateView, validateViewInvocation, viewResult } from './view.js';
76
76
  export { validateWidget, validateWidgetMessage, effectiveGrants, widgetIcon, WIDGET_SURFACES } from './widget.js';
@@ -197,7 +197,7 @@ export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHE
197
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
198
  export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
199
199
  export { createRunCache, withRunCache } from './team-cache.js';
200
- export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES } from './team-record.js';
200
+ export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES , spendOf, describeSpend } from './team-record.js';
201
201
  export { validateProject, normalizeProject, defineProject, canTransition as canProjectTransition, blankProject, projectFromForm, emptyProjectRecord, foldProject, projectProgress, ProjectError, PROJECT_STATUSES, PROJECT_ID_RE } from './project.js';
202
202
  // Job POSTINGS (F8 §12) — `jobs.js` is the scheduler and keeps `defineJob`; a posting is a JobPost here.
203
203
  export { validateJob as validateJobPost, normalizeJob as normalizeJobPost, defineJob as defineJobPost, canTransition as canJobPostTransition, applyAll, jobToRole, readyJobs, blankJob as blankJobPost, jobFromForm as jobPostFromForm, JobError as JobPostError, JOB_STATUSES as JOB_POST_STATUSES, JOB_ID_RE as JOB_POST_ID_RE } from './job.js';
@@ -207,13 +207,14 @@ export { ENGINE_KINDS as ENGINE_SPEC_KINDS, ROUTE_PREFERS, normalizePolicy, norm
207
207
  export { AGENT_ID_RE, APPLIES_TO, EGRESS_CLASSES, ASSISTANT_ID, AgentError, validateAgent, normalizeAgent, defineAgent, assistantAgent, engineOf, describeAgent, slugAgentId, resolveTeam, STARTER_AGENTS, starterAgents, blankAgent, agentFromForm, poolFor } from './agent.js';
208
208
  export { LEDGER_VERSION, LEDGER_ENTRY_KINDS, DECLINE_REASONS, WITHDRAW_AFTER, ledgerKey, normalizeCall, makeLedgerEntry, summarizeEngine } from './model-ledger.js';
209
209
  // Recruiting (F8 §12.2.4, pillars §13.4): the pool applies at once; an (agent, engine) pair is recruited; the evaluator is one optional structured call.
210
- export { RECRUIT_SCHEMA, MIN_FIT, engineRow, engineRows, needForJob, routeFor, engineWorth, applications as jobApplications, evaluatorPrompt, parseEvaluation, decide as decideRecruit, proposalFromNeeds, proposalToAgent, carveBudget, recruitEvents, recruitJob } from './recruit.js';
210
+ export { RECRUIT_SCHEMA, MIN_FIT, engineRow, engineRows, needForJob, routeFor, engineWorth, applications as jobApplications, evaluatorPrompt, parseEvaluation, decide as decideRecruit, recruitForRun, proposalFromNeeds, proposalToAgent, carveBudget, recruitEvents, recruitJob } from './recruit.js';
211
211
  export { DEFAULT_MIN_CALLS, cardOverride, applyCard } from './model-candidates.js';
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
215
  export { teamToolProvider, teamToolSpec, teamToolTimeoutMs, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
216
216
  export { workLogFor, workLogText, workLogEvidence, describeCall, WORKLOG_KINDS } from './team-worklog.js';
217
+ export { normalizeRequest, subtaskFromRequest, takeUp, takeUpLine, holdsGrants, jobFromSubtask, extendDependents, taskTree, threadRows, MAX_SUBTASKS, MAX_DEPTH, MIN_TAKEUP_FIT } from './team-subtask.js';
217
218
  export { teamLine, teamLanes } from './team-trail.js';
218
219
  export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
219
220
  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.90.0",
3
+ "version": "0.91.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",
@@ -99,6 +99,7 @@
99
99
  "./team-cache.js": "./team-cache.js",
100
100
  "./team-task.js": "./team-task.js",
101
101
  "./team-record.js": "./team-record.js",
102
+ "./team-subtask.js": "./team-subtask.js",
102
103
  "./scorecard.js": "./scorecard.js",
103
104
  "./project.js": "./project.js",
104
105
  "./job.js": "./job.js",
@@ -231,6 +232,7 @@
231
232
  "team-cache.js",
232
233
  "team-task.js",
233
234
  "team-record.js",
235
+ "team-subtask.js",
234
236
  "scorecard.js",
235
237
  "project.js",
236
238
  "job.js",
package/recruit.js CHANGED
@@ -30,8 +30,8 @@
30
30
  import { fit, engineKey, normalizeEngine } from './scorecard.js';
31
31
  import { cardOverride, DEFAULT_MIN_CALLS } from './model-ledger.js';
32
32
  import { normalizeEngineSpec, engineKeyOf, describeEngine } from './engine.js';
33
- import { engineOf, agentFromForm } from './agent.js';
34
- import { applyAll } from './job.js';
33
+ import { engineOf, agentFromForm, normalizeAgent, resolveTeam } from './agent.js';
34
+ import { applyAll, jobToRole } from './job.js';
35
35
  import { WORK_GRANTS } from './team.js';
36
36
  import { defineSchema, describeSchema, coerce } from './structured.js';
37
37
  import { normalizeBudget } from './budget.js';
@@ -422,3 +422,36 @@ export async function recruitJob(job, pool, { summaries = {}, rows = [], reach =
422
422
  const decision = decide(job, apps, { evaluation, minFit });
423
423
  return { applications: apps, evaluation, decision, events: recruitEvents(job, apps, decision, { by: decision.by === 'evaluator' ? by : 'fit', at: now, record }), prompt };
424
424
  }
425
+
426
+ // ── A run's job board (§15.2): the host's `recruit` hook, shared ─────────────────────────
427
+
428
+ /**
429
+ * What a host hands the runner as `recruit`: one pass over the pool for a sub-task's job,
430
+ * the pick made into a ROLE the run can appoint (job.js `jobToRole`, resolved through the
431
+ * pool so it carries the agent's prompt, grants, skills and a model target — agent.js
432
+ * `resolveTeam`). Returns `{ role, agentId, engine, why, fit, applications, events }` or,
433
+ * when no one is recruited, `{ why, proposal, applications, events }` with `proposal` the
434
+ * agent card a person may approve (the runner posts it). `create` is that card once
435
+ * approved — the host has persisted it to its pool before calling — and it applies with
436
+ * the rest. `events` are the project-record events of the pass (recruit.js `recruitEvents`)
437
+ * for a host that has a project to land them on; the run's own record has its own.
438
+ */
439
+ export async function recruitForRun(job, pool, { rows = [], summaries = {}, reach = 'any', chatModel = null, targetFor = null, ask = null, minFit = MIN_FIT, create = null, now = Date.now() } = {}) {
440
+ const list = (Array.isArray(pool) ? pool : []).filter((a) => a && a.id);
441
+ if (create && isRecord(create)) {
442
+ try { const made = normalizeAgent(create); if (!list.some((a) => a.id === made.id)) list.push(made); } catch { /* an unusable card applies as nothing */ }
443
+ }
444
+ const pass = await recruitJob(job, list, { summaries, rows, reach, chatModel, ask, minFit, now });
445
+ const d = pass.decision;
446
+ if (d.kind !== 'recruit') {
447
+ const card = proposalToAgent(d.proposal || proposalFromNeeds(job), job, { by: d.by || 'evaluator' });
448
+ return { why: d.why, proposal: card?.ok === false ? null : (card?.agent || card), applications: pass.applications, events: pass.events };
449
+ }
450
+ const agent = list.find((a) => a.id === d.agentId);
451
+ const base = jobToRole(job, agent);
452
+ let role = { ...base, agent: agent.id, engine: d.engine };
453
+ try {
454
+ role = resolveTeam({ name: 'recruit', roles: [role], budget: { tokens: 1 } }, list, { chatModel, targetFor }).roles[0];
455
+ } catch { /* unresolved: the runner's appointer decides from the engine */ }
456
+ return { role, agentId: d.agentId, engine: d.engine, why: d.why, fit: d.fit ?? null, applications: pass.applications, events: pass.events };
457
+ }
package/team-board.js CHANGED
@@ -77,7 +77,9 @@ export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposa
77
77
  // `failed`: the task behind the thread ended without an answer (every model on the roster
78
78
  // tried, or a hard error) — not `resolved`, which read as "done" on the board.
79
79
  export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'failed', 'approved', 'rejected']);
80
- export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision']);
80
+ // `request`: a member asks for a piece of its task to be done by someone else — the runner
81
+ // turns it into a sub-task with its own thread (team-subtask.js).
82
+ export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision', 'request']);
81
83
  export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
82
84
  export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
83
85
  export const RUNNER = 'runner';
@@ -110,7 +112,7 @@ export function foldBoard(state, ev) {
110
112
  if (x) { x.status = p.status; x.decidedBy = p.by; x.decidedAt = p.at ?? ev?.at; }
111
113
  } else if (type === 'board.thread-status' && p.threadId) {
112
114
  const t = s.threads.find((x) => x.id === p.threadId);
113
- if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; }
115
+ if (t) { t.status = p.status; if (p.status !== 'waiting') t.waitingOn = null; if (p.holder) t.holder = p.holder; }
114
116
  }
115
117
  return s;
116
118
  }
@@ -132,22 +134,25 @@ export function createBoard({ now = () => Date.now(), newId = null, state = null
132
134
 
133
135
  const api = {
134
136
  /** Open a thread. A task's thread is opened once; asking for it again returns it. */
135
- openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null } = {}) {
137
+ openThread({ id = null, taskId = null, kind = 'discussion', title = '', by = RUNNER, status = 'open', ask = null, parent = null, holder = null } = {}) {
136
138
  if (kind === 'task' && taskId) { const had = threadForTask(taskId); if (had) return had; }
137
- 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 } : {}) };
139
+ // `parent`: the task this one was requested from (a sub-task's thread hangs under its
140
+ // parent's on the board); `holder`: the member that took it.
141
+ 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 } : {}), ...(parent ? { parent } : {}), ...(holder ? { holder } : {}) };
138
142
  st.threads.push(thread);
139
143
  say('board.thread', { thread: { ...thread } });
140
144
  return thread;
141
145
  },
142
146
  /** A post in a thread; `replyTo` makes it a reply. */
143
- post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null } = {}) {
147
+ /** `proposal` rides on a draft a person decides on `{ kind: 'agent', agent, jobId }` (§15.2): the card the host creates when the post is approved. */
148
+ post({ id = null, threadId, by, kind = 'note', text = '', refs = [], replyTo = null, status = 'open', finding = null, ask = null, proposal = null } = {}) {
144
149
  const t = threadOf(threadId);
145
150
  if (!t) throw new Error(`no thread ${threadId}`);
146
151
  if (id && postOf(id)) return postOf(id);
147
152
  const post = {
148
153
  id: id || mk('p'), threadId, by: String(by || RUNNER), kind: POST_KINDS.includes(kind) ? kind : 'note',
149
154
  text: clip(text, MAX_POST_TEXT), refs: refsOf(refs), replyTo, status: POST_STATUSES.includes(status) ? status : 'open', at: now(),
150
- ...(finding ? { finding } : {}), ...(ask ? { ask } : {}),
155
+ ...(finding ? { finding } : {}), ...(ask ? { ask } : {}), ...(proposal && typeof proposal === 'object' ? { proposal } : {}),
151
156
  };
152
157
  st.posts.push(post);
153
158
  t.lastAt = post.at; t.lastBy = post.by; t.posts = (t.posts || 0) + 1;
@@ -283,7 +288,7 @@ function threadedLines(state, { taskIds, role }) {
283
288
  for (const t of threads) {
284
289
  const own = posts.filter((x) => x.threadId === t.id && x.status !== 'rejected');
285
290
  if (!own.length) continue;
286
- out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
291
+ out.push(`## ${t.kind}${t.by && t.by !== RUNNER ? ` by ${t.by}` : ''}: ${t.title}${t.holder ? ` (held by ${t.holder})` : ''}${t.status === 'resolved' && t.kind === 'ask' ? ' (answered)' : ''}`);
287
292
  const line = (x, depth) => {
288
293
  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(' · ');
289
294
  out.push(`${' '.repeat(depth)}- [${tag}] ${x.text}${x.refs?.length ? ` (refs: ${x.refs.join(', ')})` : ''}`);
package/team-plan.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // the board. That is the whole scheduling model — the same one a tool round uses.
13
13
 
14
14
  import { defineSchema, describeSchema, coerce } from './structured.js';
15
+ import { GRANT_RE } from './team.js';
15
16
 
16
17
  export const MAX_TASKS = 12;
17
18
 
@@ -28,6 +29,11 @@ export const TEAM_PLAN_SCHEMA = defineSchema({
28
29
  title: { type: 'string', required: true, max: 80 },
29
30
  prompt: { type: 'string', required: true, max: 1200, describe: 'the focused instruction for this task' },
30
31
  dependsOn: { type: 'string[]', maxItems: 6, describe: 'task ids whose findings this one needs' },
32
+ // The planner's TOOL PROPOSAL (§15.2): which of the role's tools this task will need,
33
+ // and why — posted on the board as a proposal a person reads; a task that then ends
34
+ // without touching a tool it was said to need is nudged once before it may finish.
35
+ grants: { type: 'string[]', maxItems: 6, describe: 'the tools this task needs, from the role\'s own: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr — or none' },
36
+ why: { type: 'string', max: 160, describe: 'why those tools, in a few words' },
31
37
  },
32
38
  },
33
39
  },
@@ -43,7 +49,7 @@ export function plannerPrompt(team, request) {
43
49
  '',
44
50
  `Request: ${String(request || '').trim()}`,
45
51
  '',
46
- 'Prefer tasks that can run at the same time; use dependsOn only when a task truly needs another\'s findings. Do not assign a role a task it has no tools for.',
52
+ 'Prefer tasks that can run at the same time; use dependsOn only when a task truly needs another\'s findings. Do not assign a role a task it has no tools for. For each task, say which of the role\'s tools it will need (grants) and why — do not miss a tool that would help.',
47
53
  '',
48
54
  describeSchema(TEAM_PLAN_SCHEMA),
49
55
  ].join('\n');
@@ -81,6 +87,7 @@ export function parsePlan(text, team) {
81
87
  title: String(t.title || t.prompt).slice(0, 80),
82
88
  prompt: String(t.prompt).trim(),
83
89
  dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.map(String) : [],
90
+ ...(Array.isArray(t.grants) && t.grants.length ? { grants: [...new Set(t.grants.map((g) => String(g).trim().toLowerCase()).filter((g) => GRANT_RE.test(g) && g !== 'none'))].slice(0, 6), ...(t.why ? { why: String(t.why).slice(0, 160) } : {}) } : {}),
84
91
  }))
85
92
  .slice(0, MAX_TASKS);
86
93
  const ids = new Set(tasks.map((t) => t.id));
package/team-record.js CHANGED
@@ -14,7 +14,7 @@ export const RESUMABLE_RUN_STATUSES = Object.freeze(['waiting', 'stopped', 'fail
14
14
  const TASK_TEXT_MAX = 20_000;
15
15
 
16
16
  export function emptyRun({ id, client = '', now = Date.now() } = {}) {
17
- return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
17
+ return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], jobs: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
18
18
  }
19
19
 
20
20
  const taskOf = (run, id) => run.tasks.find((x) => x.id === id);
@@ -35,9 +35,24 @@ export function foldRun(run, ev) {
35
35
  case 'plan.ready':
36
36
  run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
37
37
  // A resume replays the plan: keep what the tasks already hold (transcripts, attempts).
38
- run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : 'pending', findings: taskOf(run, t.id)?.findings || 0 }));
38
+ run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : (t.parent && !t.role ? 'unassigned' : 'pending'), findings: taskOf(run, t.id)?.findings || 0, ...(t.parent ? { parent: t.parent, requestedBy: t.requestedBy || null } : {}), ...(t.grants ? { grants: t.grants, why: t.why || '' } : {}) }));
39
39
  run.status = 'running';
40
40
  break;
41
+ // A SUB-TASK (§15.2): requested by a member mid-run, it joins the plan under its parent;
42
+ // taken by a member, recruited from the pool, created on a person's approval — or not.
43
+ case 'task.requested':
44
+ if (p.taskId && !taskOf(run, p.taskId)) {
45
+ const task = { id: p.taskId, role: null, title: p.title || p.taskId, prompt: p.prompt || '', dependsOn: Array.isArray(p.dependsOn) ? p.dependsOn : [], parent: p.parent || null, requestedBy: p.by || null, needs: p.needs || null, depth: p.depth || 1, wait: p.wait !== false };
46
+ if (run.plan) run.plan.tasks = [...(run.plan.tasks || []), task];
47
+ run.tasks.push({ id: task.id, role: null, title: task.title, status: 'requested', findings: 0, parent: task.parent, requestedBy: task.requestedBy, needs: task.needs, requestedAt: at });
48
+ }
49
+ break;
50
+ case 'task.taken': { const t = taskOf(run, p.taskId); if (t) { t.role = p.role; t.status = 'pending'; t.takenBy = { by: p.by || 'fit', role: p.role, fit: p.fit ?? null, reasons: p.reasons || [], agentId: p.agentId || null, engine: p.engine || null, why: p.why || '', at }; } const pt = run.plan?.tasks?.find((x) => x.id === p.taskId); if (pt) pt.role = p.role; break; }
51
+ case 'task.posted': { const t = taskOf(run, p.taskId); if (t) t.job = p.job || null; if (p.job && !run.jobs.some((j) => j.id === p.job.id)) run.jobs.push({ ...p.job, taskId: p.taskId, at }); break; }
52
+ case 'task.proposed': { const t = taskOf(run, p.taskId); if (t) t.proposal = { agent: p.agent || null, threadId: p.threadId || null, postId: p.postId || null, why: p.why || '', at }; break; }
53
+ case 'task.unassigned': { const t = taskOf(run, p.taskId); if (t) { t.status = 'unassigned'; t.error = p.why || null; t.endedAt = at; } const j = run.jobs.find((x) => x.taskId === p.taskId); if (j) j.status = 'failed'; break; }
54
+ case 'task.nudged': { const t = taskOf(run, p.taskId); if (t) t.nudged = [...(t.nudged || []), { grants: p.grants || [], at }]; break; }
55
+ case 'run.role-added': if (p.role?.id && !run.roles.includes(p.role.id)) { run.roles.push(p.role.id); run.recruited = [...(run.recruited || []), { ...p.role, jobId: p.jobId || null, at }]; const j = run.jobs.find((x) => x.id === p.jobId); if (j) { j.status = 'recruited'; j.recruited = { agentId: p.role.agent || p.role.id, engine: p.role.engine || null, at }; } } break;
41
56
  case 'task.started': { const t = taskOf(run, p.taskId); if (t) { t.status = 'running'; t.startedAt = at; t.error = null; } run.status = 'running'; break; }
42
57
  case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
43
58
  case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
@@ -49,7 +64,14 @@ export function foldRun(run, ev) {
49
64
  break;
50
65
  case 'task.waiting': { const t = taskOf(run, p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
51
66
  case 'task.done':
52
- case 'task.failed': { const t = taskOf(run, p.taskId); if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; t.endedAt = at; } break; }
67
+ case 'task.failed': {
68
+ const t = taskOf(run, p.taskId);
69
+ if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; t.endedAt = at; }
70
+ // A job posting's outcome is the sub-task's (§15.2.5): taken by, finished how.
71
+ const j = run.jobs.find((x) => x.taskId === p.taskId);
72
+ if (j) { j.status = type === 'task.done' ? 'done' : 'failed'; j.endedAt = at; }
73
+ break;
74
+ }
53
75
  case 'run.merging': run.status = 'merging'; break;
54
76
  case 'run.waiting': run.status = 'running'; break;
55
77
  case 'run.usage': run.usage = p.usage || run.usage; break;
@@ -100,3 +122,32 @@ export function isResumable(run) {
100
122
  if (RESUMABLE_RUN_STATUSES.includes(run.status)) return true;
101
123
  return !!run.stale && LIVE_RUN_STATUSES.includes(run.status); // its client went away mid-run
102
124
  }
125
+
126
+ /**
127
+ * The run's spend against its cap, as a board shows it: the record's last `run.usage` (or
128
+ * nothing spent yet) with `ms` measured LIVE for a run still going — the stored figure is as
129
+ * of the last task's end, and a board read "0 s" through a ten-minute research task.
130
+ */
131
+ export function spendOf(run, { now = Date.now() } = {}) {
132
+ const cap = run?.usage?.cap || run?.budget || null;
133
+ if (!cap || !Object.keys(cap).length) return null;
134
+ const spent = { tokens: 0, calls: 0, usd: 0, ms: 0, ...(run?.usage?.spent || {}) };
135
+ if (LIVE_RUN_STATUSES.includes(run?.status) && run?.startedAt) spent.ms = Math.max(spent.ms || 0, now - run.startedAt);
136
+ const pct = cap.tokens ? Math.min(100, Math.round(((spent.tokens || 0) / cap.tokens) * 100)) : cap.ms ? Math.min(100, Math.round(((spent.ms || 0) / cap.ms) * 100)) : null;
137
+ return { cap, spent, pct, exhausted: run?.usage?.exhausted || null };
138
+ }
139
+
140
+ const secs = (ms) => { const s = Math.max(0, Math.round((Number(ms) || 0) / 1000)); return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`; };
141
+ const num = (n) => (Number(n) || 0).toLocaleString('en-US');
142
+
143
+ /** One line: `1,240 / 40,000 tokens · 3 / 20 calls · 2m10s / 15m00s`. Only the capped dimensions. */
144
+ export function describeSpend(spend) {
145
+ if (!spend?.cap) return '';
146
+ const { cap, spent } = spend;
147
+ return [
148
+ cap.tokens ? `${num(spent.tokens)} / ${num(cap.tokens)} tokens` : '',
149
+ cap.calls ? `${num(spent.calls)} / ${num(cap.calls)} calls` : '',
150
+ cap.usd ? `$${(Number(spent.usd) || 0).toFixed(2)} / $${Number(cap.usd).toFixed(2)}` : '',
151
+ cap.ms ? `${secs(spent.ms)} / ${secs(cap.ms)}` : '',
152
+ ].filter(Boolean).join(' · ');
153
+ }
package/team-run.js CHANGED
@@ -8,6 +8,10 @@
8
8
  //
9
9
  // Tasks run in WAVES (team-plan.js): everything with its dependencies met runs together
10
10
  // through the same pool a tool round uses; a dependent task waits and reads the board. A
11
+ // member may REQUEST a piece of its task be done by someone else (board tool `request`,
12
+ // team-subtask.js): the sub-task joins the plan with its own thread under the parent's, is
13
+ // offered to the members that fit, then to the pool through the host's `recruit`, then
14
+ // proposed as a new agent to the person — so the plan is a tree the board draws as one. A
11
15
  // task ends with findings; the merge turns the board into ONE proposal — agreed claims
12
16
  // (converge, W7), a judged answer, or the members' work side by side — and the proposal is
13
17
  // what a person sees. Nothing here lands anywhere.
@@ -19,8 +23,11 @@
19
23
  import { normalizeTeam } from './team.js';
20
24
  import { normalizeEngine, normalizeScm } from './scorecard.js';
21
25
  import { createBudget } from './budget.js';
22
- import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
23
- import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
26
+ import { fixedPlan, plannerPrompt, parsePlan } from './team-plan.js';
27
+ import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER, PERSON } from './team-board.js';
28
+ import { subtaskFromRequest, takeUp, takeUpLine, jobFromSubtask, extendDependents, MAX_SUBTASKS, MAX_DEPTH } from './team-subtask.js';
29
+ import { proposalToAgent, proposalFromNeeds } from './recruit.js';
30
+ import { grantsNeededFor } from './tool-need.js';
24
31
  import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
25
32
  import { createRunCache, withRunCache } from './team-cache.js';
26
33
  import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote, isThought } from './team-task.js';
@@ -94,6 +101,14 @@ export function dryRunTeam(team, request, { appoint = null } = {}) {
94
101
  * `reasons` and `alternatives` are why this one and who else could have —
95
102
  * said as `task.routed`, which every run records from here on (pillars §13)
96
103
  * @param runRecipe `async (name, params) => result` for `mode: 'recipe'` roles (optional)
104
+ * @param recruit `async (job, { runId, requestedBy, create? }) => { role, agentId?, engine?, why?, fit? }
105
+ * | { proposal? , why? } | null` — the host's job board (recruit.js over its
106
+ * pool): a sub-task nobody in the run fits is posted as `job`; the host
107
+ * answers with the role to add (job.js jobToRole, resolved to a model), or
108
+ * nothing, optionally with the agent it would propose. With `create` (the
109
+ * card a person approved on the board) the host adds it to the pool first.
110
+ * Absent, a sub-task nobody fits is recorded unassigned.
111
+ * @param projectId the project a job posting belongs to, when the run has one (else the run id)
97
112
  * @param emit `(type, payload)` — run.started · plan.ready · task.started · task.finding ·
98
113
  * task.done · task.failed · run.merging · run.done; the host forwards them to
99
114
  * its UI and to the gateway's run store
@@ -111,6 +126,7 @@ export async function runTeam({
111
126
  // The host's control channel (team-task.js createControl): a person hands a task to another
112
127
  // model from the board, on either client; the runner continues the task's transcript there.
113
128
  control = null,
129
+ recruit = null, projectId = null,
114
130
  } = {}) {
115
131
  if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
116
132
  const t = normalizeTeam(team); // throws on a team without a budget — O1
@@ -178,9 +194,16 @@ export async function runTeam({
178
194
  }
179
195
  }
180
196
  if (!tasks) tasks = fixedPlan(t, request);
181
- say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn })) });
197
+ say('plan.ready', { by: planBy, tasks: tasks.map((x) => ({ id: x.id, role: x.role, title: x.title, dependsOn: x.dependsOn, ...(x.parent ? { parent: x.parent, requestedBy: x.requestedBy || null } : {}), ...(x.grants ? { grants: x.grants, why: x.why || '' } : {}) })) });
182
198
  // A thread per task, before anything runs: a member's findings and replies have a home.
183
- for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: RUNNER });
199
+ for (const task of tasks) board.openThread({ taskId: task.id, kind: 'task', title: task.title || task.id, by: task.requestedBy || RUNNER, ...(task.parent ? { parent: task.parent } : {}), ...(task.role && task.parent ? { holder: task.role } : {}) });
200
+ // The planner's TOOL PROPOSAL (§15.2): which tools each task will need and why, as a
201
+ // proposal thread a person reads — and what the nudge below holds the member to.
202
+ if (!resume && tasks.some((x) => x.grants?.length)) {
203
+ const th = board.openThread({ kind: 'proposal', title: 'Tools per task', by: t.roles.find((r) => r.id === (tasks[0]?.role))?.id || RUNNER });
204
+ const lines = tasks.filter((x) => x.grants?.length).map((x) => { const held = t.roles.find((r) => r.id === x.role)?.grants || []; const missing = x.grants.filter((g) => !held.includes(g) && !(g.startsWith('mcp:') && held.includes('mcp'))); return `${x.role} (${x.id}): ${x.grants.join(', ')}${x.why ? ` — ${x.why}` : ''}${missing.length ? ` (not granted: ${missing.join(', ')})` : ''}`; });
205
+ board.post({ threadId: th.id, by: RUNNER, kind: 'draft', status: 'proposed', text: lines.join('\n') });
206
+ }
184
207
 
185
208
  const finish = (status, extra = {}) => {
186
209
  const usage = budget.snapshot();
@@ -205,17 +228,99 @@ export async function runTeam({
205
228
  };
206
229
  let waitingOnPerson = false; // a task that timed out on its ask — the run checkpoints
207
230
 
208
- // ── fan out, in waves ─────────────────────────────────────────────────────────────────
231
+ // ── fan out: everything ready runs together; a sub-task requested mid-run joins the plan ──
209
232
  let overBudget = false;
210
233
  let budgetAsked = !!resume?.budgetAsked;
211
- for (const wave of waves(tasks.filter((x) => !carried.has(x.id)))) {
212
- if (stopped() || overBudget || waitingOnPerson) break;
213
- await pool(wave, maxConcurrency, async (task) => {
214
- if (stopped() || overBudget || waitingOnPerson) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
234
+ const running = new Set();
235
+ const isDone = (tid) => carried.has(tid) || tasksOut.some((x) => x.id === tid);
236
+ // A sub-task with no holder cannot run; it is recorded `unassigned` at the end.
237
+ const ready = () => tasks.filter((x) => x.role && !isDone(x.id) && !running.has(x.id) && (x.dependsOn || []).every(isDone));
238
+ let subtasks = tasks.filter((x) => x.parent).length;
239
+
240
+ /**
241
+ * A member's REQUEST (board tool `request`): a sub-task with its own thread, offered to the
242
+ * run's members first, then to the pool through the host's `recruit`, then proposed as a
243
+ * new agent to the person. With `wait` the requester gets the findings back in its turn;
244
+ * without, the sub-task runs after it and whoever depended on the requester reads it too.
245
+ */
246
+ const onRequest = async (parentTask, role, req) => {
247
+ if (subtasks >= MAX_SUBTASKS) return { error: `This run has reached its limit of ${MAX_SUBTASKS} sub-tasks. Do this yourself, or say what you could not do.` };
248
+ if ((Number(parentTask.depth) || 0) >= MAX_DEPTH) return { error: 'A sub-task cannot delegate further. Do this yourself, or say what you could not do.' };
249
+ if (stopped() || overBudget) return { error: 'The run is stopping; do what you can and finish.' };
250
+ subtasks += 1;
251
+ const sub = subtaskFromRequest(req, parentTask, { id: `${parentTask.id}-s${subtasks}`, by: role.id, now: now() });
252
+ tasks.push(sub);
253
+ say('task.requested', { taskId: sub.id, parent: parentTask.id, by: role.id, title: sub.title, prompt: sub.prompt, needs: sub.needs, dependsOn: sub.dependsOn, depth: sub.depth, wait: sub.wait, postId: req.postId || null });
254
+ const thread = board.openThread({ taskId: sub.id, kind: 'task', title: sub.title, by: role.id, parent: parentTask.id });
255
+ // 1. The run's own members, by fit on skills and grants.
256
+ let pick = takeUp(sub, t.roles, { exclude: [role.id] });
257
+ let takenBy = null;
258
+ if (pick.roleId) {
259
+ takenBy = { by: 'fit', roleId: pick.roleId, fit: pick.fit, reasons: pick.reasons };
260
+ } else if (typeof recruit === 'function') {
261
+ // 2. The job board: the pool applies, the host recruits an (agent, engine) pair.
262
+ const job = jobFromSubtask(sub, { runId: id, projectId: projectId || null, by: role.id });
263
+ say('task.posted', { taskId: sub.id, job, why: pick.why });
264
+ board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Posted on the job board — ${pick.why}. Needs: ${describeNeeds(sub.needs)}.` });
265
+ let r = null;
266
+ try { r = await recruit(job, { runId: id, requestedBy: role.id }); } catch (e) { board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Recruiting failed: ${String(e?.message || e).slice(0, 200)}` }); }
267
+ if (r?.role) {
268
+ takenBy = { by: 'recruit', roleId: addRole(r.role, job), agentId: r.agentId || r.role.agent || null, engine: r.engine || null, why: r.why || '', fit: r.fit ?? null };
269
+ } else {
270
+ // 3. Nobody applies: propose the agent the job describes — a person decides; nothing
271
+ // is created here. With asks on, the person is asked now and the run goes on either way.
272
+ const card = r?.proposal || proposalToAgent(proposalFromNeeds(job), job, { by: 'runner' });
273
+ const agentCard = card?.ok === false ? null : (card?.agent || card);
274
+ const pth = board.openThread({ kind: 'proposal', title: `New agent for "${sub.title}"`, by: RUNNER, parent: parentTask.id });
275
+ const post = board.post({ threadId: pth.id, by: RUNNER, kind: 'draft', status: 'proposed', text: `No one in the pool fits "${sub.title}"${r?.why ? ` — ${r.why}` : ''}. Proposed: ${agentCard?.name || sub.title}${agentCard?.skills?.length ? ` — skills ${agentCard.skills.join(', ')}` : ''}${agentCard?.grants?.length ? `; grants ${agentCard.grants.join(', ')}` : ''}.`, refs: [`task:${sub.id}`], ...(agentCard ? { proposal: { kind: 'agent', agent: agentCard, jobId: job.id } } : {}) });
276
+ say('task.proposed', { taskId: sub.id, threadId: pth.id, postId: post.id, agent: agentCard, job, why: r?.why || pick.why });
277
+ const a = agentCard ? await askPerson({ type: 'permission', taskId: sub.id, text: `No one fits "${sub.title}". Create the agent "${agentCard.name}" (${describeNeeds({ skills: agentCard.skills, grants: agentCard.grants })}) and give it the job?`, options: ['Create it', 'Skip'] }) : null;
278
+ if (a && /create|yes|approve|allow|go/i.test(a.text)) {
279
+ board.decide(post.id, 'approved', a.by || PERSON);
280
+ let made = null;
281
+ try { made = await recruit(job, { runId: id, requestedBy: role.id, create: agentCard }); } catch (e) { board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Creating the agent failed: ${String(e?.message || e).slice(0, 200)}` }); }
282
+ if (made?.role) takenBy = { by: 'created', roleId: addRole(made.role, job), agentId: made.agentId || made.role.agent || null, engine: made.engine || null, why: made.why || 'created for this job', fit: null };
283
+ } else if (a) board.decide(post.id, 'rejected', a.by || PERSON);
284
+ }
285
+ }
286
+ if (!takenBy) {
287
+ const why = typeof recruit === 'function' ? 'nobody in the run fits and no agent was recruited' : `${pick.why}; this run has no pool to recruit from`;
288
+ board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `Not taken: ${why}.` });
289
+ board.setThreadStatus(thread.id, 'failed');
290
+ say('task.unassigned', { taskId: sub.id, why });
291
+ return { taskId: sub.id, takenBy: null, why, hint: 'Nobody could take this. Do what you can yourself and say what is missing.' };
292
+ }
293
+ sub.role = takenBy.roleId;
294
+ board.setThreadStatus(thread.id, 'open', { holder: takenBy.roleId });
295
+ board.post({ threadId: thread.id, by: RUNNER, kind: 'decision', text: takenBy.by === 'fit' ? takeUpLine(sub, pick) : `${takenBy.roleId} ${takenBy.by === 'created' ? 'was created and' : 'was recruited from the pool and'} took: ${sub.title}${takenBy.why ? ` — ${takenBy.why}` : ''}` });
296
+ say('task.taken', { taskId: sub.id, role: takenBy.roleId, by: takenBy.by, fit: takenBy.fit ?? null, reasons: takenBy.reasons || [], agentId: takenBy.agentId || null, engine: takenBy.engine || null, why: takenBy.why || '' });
297
+ if (sub.wait) {
298
+ // Nested: the requester's turn holds while the sub-task runs; its findings come back here.
299
+ const row = await runTask(sub);
300
+ return { taskId: sub.id, takenBy: takenBy.roleId, status: row.status, ...(row.error ? { error: row.error } : {}), findings: (row.findings || []).map((f) => ({ kind: f.kind, text: f.text, refs: f.refs })), text: String(row.text || '').slice(0, 4000) };
301
+ }
302
+ const extended = extendDependents(tasks, parentTask.id, sub.id);
303
+ return { taskId: sub.id, takenBy: takenBy.roleId, queued: true, hint: `It runs after your task; ${extended.length ? `${extended.join(', ')} will read it too` : 'its findings will be on the board'}. Finish your task and say what depends on it.` };
304
+ };
305
+ /** A recruited agent joins the run as a role — the job's grants narrow it — and is appointed like any other. */
306
+ const addRole = (role, job) => {
307
+ const base = { ...role, id: String(role.id || job.id), grants: role.grants || job.needs?.grants || ['none'] };
308
+ const nt = normalizeTeam({ name: t.name, roles: [base], budget: t.budget });
309
+ const r = { ...nt.roles[0], ...(role.model ? { model: role.model } : {}), ...(role.engine ? { engine: role.engine } : {}), ...(role.skills ? { skills: role.skills } : {}), ...(role.workdir ? { workdir: role.workdir } : {}), recruited: true };
310
+ if (!t.roles.some((x) => x.id === r.id)) { t.roles.push(r); say('run.role-added', { role: { id: r.id, name: r.name, agent: r.agent || null, grants: r.grants, model: r.model || null, engine: r.engine || null }, jobId: job.id }); }
311
+ return r.id;
312
+ };
313
+
314
+ const runTask = async (task) => {
315
+ running.add(task.id);
316
+ try { return await runTaskInner(task); } finally { running.delete(task.id); }
317
+ };
318
+ const runTaskInner = async (task) => {
319
+ if (stopped() || overBudget || waitingOnPerson) { const row = { id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }; tasksOut.push(row); return row; }
215
320
  const role = roleOf(task.role);
216
321
  const t0 = now();
217
322
  const was = interrupted.get(task.id) || null;
218
- say('task.started', { taskId: task.id, role: task.role, title: task.title, ...(was ? { resumed: true, steps: was.transcript.length } : {}) });
323
+ say('task.started', { taskId: task.id, role: task.role, title: task.title, ...(task.parent ? { parent: task.parent } : {}), ...(was ? { resumed: true, steps: was.transcript.length } : {}) });
219
324
  let text = '';
220
325
  let usage = null;
221
326
  let status = 'ok';
@@ -247,7 +352,14 @@ export async function runTeam({
247
352
  const added = newSteps(before, after);
248
353
  if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map(stamp) });
249
354
  };
355
+ // The tool-choice guard (§15.2): what this task's wording — and the planner's proposal —
356
+ // say it needs among what the role holds. A model attempt that ends with zero calls
357
+ // while holding one of these is nudged once, then may finish.
358
+ const grantsHeld = role?.grants || [];
359
+ const mustUse = role?.mode === 'model' ? [...new Set([...grantsNeededFor(task.prompt, { held: grantsHeld }), ...(task.grants || []).filter((g) => grantsHeld.includes(g) || (g.startsWith('mcp:') && grantsHeld.includes('mcp')))])] : [];
360
+ let nudged = false;
250
361
  try {
362
+ if (!role) throw new Error(`no role "${task.role}" in the team`);
251
363
  if (role.mode === 'recipe') {
252
364
  if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
253
365
  const r = await runRecipe(role.recipe, { request: String(request || ''), task: task.prompt });
@@ -265,6 +377,7 @@ export async function runTeam({
265
377
  const boardTool = boardToolProvider({
266
378
  board, role: role.id, taskId: task.id, taskIds: task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
267
379
  onAsk: (thread) => { say('task.waiting', { taskId: task.id, role: role.id, threadId: thread.id, text: thread.title }); },
380
+ onRequest: (req) => onRequest(task, role, req),
268
381
  waitFor: askMs > 0 ? async (threadId, ms, sig) => {
269
382
  const a = await box.wait(threadId, ms, sig);
270
383
  if (!a && !stopped()) { askedAndWaiting = threadId; taskAc.abort(); }
@@ -293,6 +406,8 @@ export async function runTeam({
293
406
  note = continuationNote({ kind: 'handoff', from: lastModel, to: handoffTo.model, reason: `handed off by ${handoffTo.by}` });
294
407
  handoffTo = null;
295
408
  taskAc = new AbortController(); signal?.addEventListener?.('abort', onRunAbort, { once: true });
409
+ } else if (nudged && lastModel && !exclude.has(lastModel)) {
410
+ m = { model: lastModel, mode: role.mode }; // the nudge continues on the same model
296
411
  } else {
297
412
  m = modelFor(role, excluding(exclude));
298
413
  }
@@ -337,7 +452,20 @@ export async function runTeam({
337
452
  // died after its tool calls — is not a done task: three members "completed" empty
338
453
  // once, the run merged nothing, and the caller ran the team again. It is treated
339
454
  // like an unavailable model, so the next one on the roster gets the task.
340
- if (res?.ok && String(res?.text || '').trim()) { text = String(res.text); break; }
455
+ if (res?.ok && String(res?.text || '').trim()) {
456
+ // Answered from memory while holding a tool the task calls for: one nudge, on
457
+ // the same model, continuing the transcript — then whatever it says stands.
458
+ const usedNone = !nudged && mustUse.length && routed?.engine?.kind !== 'harness' && !transcript.some((x) => x.role === 'assistant' && Array.isArray(x.tool_calls) && x.tool_calls.some((c) => c.function?.name && c.function.name !== 'board'));
459
+ if (usedNone && budget.canAfford({ tokens: 0 }) && !stopped()) {
460
+ nudged = true;
461
+ note = continuationNote({ kind: 'nudge', reason: mustUse.join(', ') });
462
+ const th = board.threadForTask(task.id);
463
+ if (th) board.post({ threadId: th.id, by: RUNNER, kind: 'note', text: `${role.id} answered without using ${mustUse.join(', ')}, which the task calls for — asked once to use it before finishing.` });
464
+ say('task.nudged', { taskId: task.id, role: role.id, grants: mustUse });
465
+ continue;
466
+ }
467
+ text = String(res.text); break;
468
+ }
341
469
  // A member that wrote on the board and then ran out of turn has still answered:
342
470
  // what it posted in its own thread during this attempt is its answer. A relayed
343
471
  // agent that posted its assessment and kept searching past the cap was being
@@ -363,45 +491,54 @@ export async function runTeam({
363
491
  } finally {
364
492
  unsubscribe?.();
365
493
  }
366
- const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
367
- if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
494
+ const findings = status === 'ok' ? parseFindings(text, { role: role?.id, taskId: task.id }) : [];
495
+ if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role?.id, finding: f }); }
368
496
  const thread = board.threadForTask(task.id);
369
497
  // The thread says how the task ended. A failure is posted in it as well — a person reading
370
498
  // the board sees "researcher failed: network error" where it happened, and what was tried.
371
499
  if (thread && status === 'ok') board.setThreadStatus(thread.id, 'resolved');
372
500
  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(', ')})` : ''}.` });
501
+ board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role?.id || task.role} ${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
502
  board.setThreadStatus(thread.id, 'failed');
375
503
  }
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 } : {}) };
504
+ const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(task.parent ? { parent: task.parent } : {}), ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
377
505
  tasksOut.push(row);
378
506
  say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
379
507
  // The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
380
508
  // in which role — produced here, attested by the store, never written by the agent.
381
- if (status === 'ok' || status === 'failed') {
509
+ if (role && (status === 'ok' || status === 'failed')) {
382
510
  say('task.scored', {
383
511
  agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), engine: routed?.engine || null, scm: scm || undefined, outcome: status === 'ok' ? 'task.done' : 'task.failed',
384
512
  size: { ms: row.ms, steps: (row.transcript || []).length, tools: (row.transcript || []).filter((m) => m.role === 'tool').length, findings: findings.length, tokens: usage ? Number(usage.input_tokens || usage.prompt_tokens || 0) + Number(usage.output_tokens || usage.completion_tokens || 0) : 0 },
385
513
  roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
386
514
  refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
515
+ ...(task.parent ? { parent: task.parent, requestedBy: task.requestedBy || null } : {}),
387
516
  });
388
517
  }
389
518
  // The spend so far, after every task — a ledger reads it live instead of at the end.
390
519
  say('run.usage', { usage: budget.snapshot() });
391
520
  if (budget.exhausted()) overBudget = true;
392
- });
521
+ return row;
522
+ };
523
+
524
+ for (;;) {
525
+ if (stopped() || overBudget || waitingOnPerson) break;
526
+ const wave = ready();
527
+ if (!wave.length) break;
528
+ await pool(wave, maxConcurrency, runTask);
393
529
  // Over budget with work left: ask the person ONCE for more, on the board, before stopping.
394
530
  if (overBudget && !budgetAsked && !stopped()) {
395
531
  budgetAsked = true;
396
- const left = tasks.filter((x) => !tasksOut.some((y) => y.id === x.id)).length;
532
+ const left = tasks.filter((x) => x.role && !tasksOut.some((y) => y.id === x.id)).length;
397
533
  const spent = budget.snapshot().spent;
398
534
  const what = left ? `${left} task${left === 1 ? '' : 's'} and the merge left` : 'only the merge left';
399
535
  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 ${what}. Raise it by half, or stop here with what it has?`, options: ['Raise by half', 'Stop here'] });
400
536
  if (a && /raise|allow|yes|more|continue/i.test(a.text)) { budget.raise(1.5); overBudget = false; }
401
537
  }
402
538
  }
403
- // Every planned task gets a row — what never ran is recorded as skipped, not forgotten.
404
- 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: [] });
539
+ // Every planned task gets a row — what never ran is recorded as skipped, not forgotten;
540
+ // a sub-task nobody took is `unassigned`, which is its own kind of undone.
541
+ 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: task.parent && !task.role ? 'unassigned' : 'skipped', text: '', findings: [], ...(task.parent ? { parent: task.parent } : {}) });
405
542
  if (stopped()) return finish('stopped');
406
543
  if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
407
544
  // Over budget is a STOP only when it left work undone; a budget spent on the last task
@@ -485,6 +622,7 @@ export function resumeTeam({ checkpoint, ...deps } = {}) {
485
622
  }
486
623
 
487
624
  const lastModelOf = (attempts) => (attempts || []).at(-1)?.model || null;
625
+ const describeNeeds = (n) => [n?.skills?.length ? `skills ${n.skills.join(', ')}` : '', n?.grants?.length ? `grants ${n.grants.join(', ')}` : '', n?.tools?.length ? `tools ${n.tools.join(', ')}` : ''].filter(Boolean).join('; ') || 'nothing in particular';
488
626
  const toolNamesOf = (transcript) => [...new Set((transcript || []).flatMap((m) => (m.role === 'assistant' && Array.isArray(m.tool_calls) ? m.tool_calls.map((c) => c.function?.name).filter(Boolean) : [])))];
489
627
 
490
628
  /** No model: the members' work side by side, findings first — always available. */
@@ -0,0 +1,192 @@
1
+ // A sub-task — a member's request turned into a job on the run (F8 §15.2).
2
+ //
3
+ // A member breaks its task down by posting a REQUEST in its thread ("writer: I need the
4
+ // valuation checked"); the runner turns each into a sub-task with its own thread, a `parent`
5
+ // and the parent's dependencies, so the plan is a TREE. Take-up is proactive: the sub-task
6
+ // is offered to the run's other members first — `takeUp` scores each on the skills and
7
+ // grants the request names (scorecard.js `fit`, the same score a job posting uses) and the
8
+ // best fit that meets the hard needs claims it. Nobody fits → the job board: `jobFromSubtask`
9
+ // is the posting the pool applies to (recruit.js), and the host's `recruit` hook answers with
10
+ // a role or nothing; nothing → an agent is PROPOSED to a person, never created (§7).
11
+ //
12
+ // Pure: no model call, no clock beyond what is handed in. The runner (team-run.js) owns the
13
+ // flow; this module owns the shapes and the decisions that can be tested without a run.
14
+
15
+ import { fit } from './scorecard.js';
16
+ import { GRANT_RE, normalizeGrants } from './team.js';
17
+ import { normalizeJob } from './job.js';
18
+
19
+ export const MAX_SUBTASKS = 8; // per run — a member that needs more than this is re-planning, not delegating
20
+ export const MAX_DEPTH = 2; // a sub-task may request a sub-sub-task; not deeper
21
+ export const MIN_TAKEUP_FIT = 0.5;
22
+ export const MAX_BRIEF = 4000;
23
+
24
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
25
+ const lower = (xs) => (Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => String(x).trim().toLowerCase()).filter(Boolean);
26
+ const uniq = (xs) => [...new Set(xs)];
27
+
28
+ /** The request as a member states it, checked: a title, a brief, and what it needs. */
29
+ export function normalizeRequest(input) {
30
+ const title = clip(input?.title, 120);
31
+ const brief = clip(input?.brief ?? input?.text, MAX_BRIEF);
32
+ if (!title && !brief) return { ok: false, error: 'a request needs a title and a brief — what to do and what done looks like' };
33
+ const grants = uniq(lower(input?.grants).filter((g) => GRANT_RE.test(g) && g !== 'none'));
34
+ return {
35
+ ok: true,
36
+ request: {
37
+ title: title || clip(brief, 80),
38
+ brief: brief || title,
39
+ needs: { skills: uniq(lower(input?.skills)).slice(0, 12), tools: uniq(lower(input?.tools)).slice(0, 12), grants: grants.slice(0, 8) },
40
+ wait: input?.wait !== false && input?.wait !== 'false',
41
+ },
42
+ };
43
+ }
44
+
45
+ /**
46
+ * The sub-task a request becomes: a task row like any planned one (team-plan.js) plus
47
+ * `parent`, `depth`, `needs`, `requestedBy`; `role` is null until someone takes it. It
48
+ * inherits the parent's dependencies so it may read what the parent read.
49
+ */
50
+ export function subtaskFromRequest(request, parent, { id, by, now = Date.now() } = {}) {
51
+ const r = request;
52
+ return {
53
+ id,
54
+ role: null,
55
+ title: r.title,
56
+ prompt: [
57
+ `Sub-task requested by ${by} while working on "${clip(parent?.title || parent?.id, 120)}": ${r.title}`,
58
+ r.brief,
59
+ 'Do this one thing and finish with your findings; whoever asked reads them on the board.',
60
+ ].join('\n\n'),
61
+ dependsOn: uniq([...(parent?.dependsOn || [])]).filter((d) => d !== id),
62
+ parent: parent?.id || null,
63
+ depth: (Number(parent?.depth) || 0) + 1,
64
+ needs: { skills: [...r.needs.skills], tools: [...r.needs.tools], grants: [...r.needs.grants] },
65
+ requestedBy: by,
66
+ requestedAt: now,
67
+ wait: !!r.wait,
68
+ };
69
+ }
70
+
71
+ /** Does this role hold every grant the request names? `mcp` covers `mcp:<server>`. */
72
+ export function holdsGrants(role, grants) {
73
+ const have = new Set(normalizeGrants(role?.grants || []));
74
+ if (have.has('none') && (grants || []).length) return false;
75
+ return (grants || []).every((g) => have.has(g) || (g.startsWith('mcp:') && have.has('mcp')));
76
+ }
77
+
78
+ const coversSkills = (role, skills) => {
79
+ if (!skills?.length) return true;
80
+ const has = new Set(lower(role?.skills));
81
+ return skills.some((s) => has.has(s));
82
+ };
83
+
84
+ /**
85
+ * Offer a sub-task to the run's members: the best fit among those that hold every grant it
86
+ * needs and at least one skill it names (when it names any), at or above `minFit`. The
87
+ * requester is excluded — a request is a delegation — as are recipe roles (a recipe cannot
88
+ * take an arbitrary task) and anything in `exclude`. `summaries` are scorecard cards by
89
+ * agent or role id, when the host has them. Returns `{ roleId, fit, reasons }` or null with
90
+ * `why` on the side: `{ roleId: null, why }`.
91
+ */
92
+ export function takeUp(task, roles, { exclude = [], summaries = {}, minFit = MIN_TAKEUP_FIT } = {}) {
93
+ const skip = new Set([task?.requestedBy, ...exclude].filter(Boolean));
94
+ const needs = task?.needs || { skills: [], tools: [], grants: [] };
95
+ const candidates = (roles || []).filter((r) => r && !skip.has(r.id) && r.mode !== 'recipe');
96
+ if (!candidates.length) return { roleId: null, why: 'no other member in the run' };
97
+ const scored = [];
98
+ const rejected = [];
99
+ for (const r of candidates) {
100
+ if (!holdsGrants(r, needs.grants)) { rejected.push(`${r.id} lacks ${needs.grants.filter((g) => !holdsGrants(r, [g])).join(', ')}`); continue; }
101
+ if (!coversSkills(r, needs.skills)) { rejected.push(`${r.id} has none of: ${needs.skills.join(', ')}`); continue; }
102
+ const f = fit({ needs, size: { steps: 0 } }, { skills: r.skills || [], tools: r.grants || [], grants: r.grants || [] }, summaries[r.agent || r.id] || null, { adjust: false });
103
+ scored.push({ roleId: r.id, fit: f.score, reasons: f.reasons });
104
+ }
105
+ scored.sort((a, b) => b.fit - a.fit);
106
+ const best = scored[0];
107
+ if (best && best.fit >= minFit) return best;
108
+ const why = best ? `${best.roleId} fits best at ${Math.round(best.fit * 100)}%, under the ${Math.round(minFit * 100)}% floor` : (rejected.length ? rejected.join('; ') : 'no member fits');
109
+ return { roleId: null, why };
110
+ }
111
+
112
+ /**
113
+ * The posting the pool applies to when nobody in the run fits (job.js). The run stands in
114
+ * for a project when there is none: `projectId` is the run's. The brief carries who asked
115
+ * and for what, so the evaluator and a person read the context.
116
+ */
117
+ export function jobFromSubtask(task, { runId, projectId = null, budget = null, by = null } = {}) {
118
+ return normalizeJob({
119
+ id: task.id,
120
+ projectId: projectId || runId,
121
+ title: task.title,
122
+ brief: task.prompt,
123
+ needs: task.needs,
124
+ ...(budget ? { budget } : {}),
125
+ status: 'open',
126
+ postedBy: by || task.requestedBy || 'runner',
127
+ postedAt: task.requestedAt || Date.now(),
128
+ dependsOn: [],
129
+ origin: { kind: 'subtask', runId, parent: task.parent || null, requestedBy: task.requestedBy || null },
130
+ });
131
+ }
132
+
133
+ /**
134
+ * A queued sub-task (the requester did not wait for it) must be read by whoever would have
135
+ * read the requester: every task that depends on the parent now depends on the sub-task too.
136
+ * Mutates the plan in place; returns the ids it extended.
137
+ */
138
+ export function extendDependents(tasks, parentId, subtaskId) {
139
+ const out = [];
140
+ for (const t of tasks || []) {
141
+ if (!t || t.id === subtaskId) continue;
142
+ if ((t.dependsOn || []).includes(parentId) && !(t.dependsOn || []).includes(subtaskId)) { t.dependsOn = [...t.dependsOn, subtaskId]; out.push(t.id); }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** The plan as a tree — roots first, each with its children — for a board that draws it as one. */
148
+ export function taskTree(tasks) {
149
+ const list = (tasks || []).filter(Boolean);
150
+ const ids = new Set(list.map((t) => t.id));
151
+ const byParent = new Map();
152
+ for (const t of list) {
153
+ const key = t.parent && ids.has(t.parent) ? t.parent : null;
154
+ if (!byParent.has(key)) byParent.set(key, []);
155
+ byParent.get(key).push(t);
156
+ }
157
+ const build = (key, seen) => (byParent.get(key) || []).filter((t) => !seen.has(t.id)).map((t) => ({ task: t, children: build(t.id, new Set([...seen, t.id])) }));
158
+ return build(null, new Set());
159
+ }
160
+
161
+ /** One line a person reads: "researcher took: check valuation (fit 85%)". */
162
+ export function takeUpLine(task, pick) {
163
+ if (!pick?.roleId) return `nobody in the run fits "${task.title}"${pick?.why ? `: ${pick.why}` : ''}`;
164
+ return `${pick.roleId} took: ${task.title} (fit ${Math.round((pick.fit || 0) * 100)}%${pick.reasons?.length ? ` — ${pick.reasons[0]}` : ''})`;
165
+ }
166
+
167
+ /**
168
+ * A run's threads as the board lists them: a sub-task's thread (one whose `parent` names a
169
+ * task) follows its parent's thread, indented — `depth` on each row — so the tree reads as
170
+ * one. Roots keep the order given (a board sorts them newest first); children come in the
171
+ * order they were requested. A thread whose parent is not on the board is a root.
172
+ */
173
+ export function threadRows(threads) {
174
+ const list = (threads || []).filter(Boolean);
175
+ const byTask = new Map(list.filter((t) => t.kind === 'task' && t.taskId).map((t) => [t.taskId, t]));
176
+ const children = new Map();
177
+ for (const t of list) {
178
+ if (!t.parent || !byTask.has(t.parent) || byTask.get(t.parent) === t) continue;
179
+ const key = byTask.get(t.parent).id;
180
+ if (!children.has(key)) children.set(key, []);
181
+ children.get(key).push(t);
182
+ }
183
+ const isChild = new Set([...children.values()].flat().map((t) => t.id));
184
+ const out = [];
185
+ const walk = (t, depth, seen) => {
186
+ if (seen.has(t.id)) return;
187
+ out.push({ ...t, depth });
188
+ for (const c of (children.get(t.id) || []).sort((a, b) => (a.at || 0) - (b.at || 0))) walk(c, depth + 1, new Set([...seen, t.id]));
189
+ };
190
+ for (const t of list) if (!isChild.has(t.id)) walk(t, 0, new Set());
191
+ return out;
192
+ }
package/team-task.js CHANGED
@@ -64,9 +64,11 @@ export function newSteps(prev, next) {
64
64
  * handoff — the previous attempt (another model / agent) stopped: an error, a person's choice
65
65
  * resume — the run itself was stopped or died and is being resumed from the record
66
66
  * answer — the task waited on a person and the answer is now on the board
67
+ * nudge — the attempt answered without touching a tool the task was said to need (§15.2)
67
68
  */
68
69
  export function continuationNote({ kind = 'handoff', from = '', to = '', reason = '', answer = '' } = {}) {
69
70
  const who = from ? ` by ${from}` : '';
71
+ if (kind === 'nudge') return `You answered without using ${reason || 'the tools you were given'}, which this task calls for. Use them now — look it up rather than answer from memory — then finish with your findings. If a tool is truly not needed, say why in one line and finish.`;
70
72
  if (kind === 'answer') return `The user has answered your question on the board (see the board, or: ${answer}). Continue the task from where you left off — do not repeat work already done above — and finish with your findings.`;
71
73
  if (kind === 'resume') return `This task was interrupted (${reason || 'the run was stopped'}) and is being resumed${to ? ` by ${to}` : ''}. Everything above is the work done so far — read it, do not redo it. Continue from where it stopped and finish with your findings.`;
72
74
  return `You are continuing this task. A previous attempt${who} stopped (${reason || 'it did not finish'}). Everything above is its work so far — read it, do not redo lookups already made. Continue from where it stopped and finish with your findings.`;
package/team-worklog.js CHANGED
@@ -131,7 +131,7 @@ export function workLogText(entries, { resultChars = 200 } = {}) {
131
131
  * any opinion (a judge's, a peer's, a person's) is added beside it.
132
132
  */
133
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 };
134
+ const ev = { calls: 0, results: 0, resultErrors: 0, thoughts: 0, texts: 0, attempts: 0, handoffs: 0, posts: 0, findings: 0, requests: 0, decided: { approved: 0, rejected: 0 }, status: null, ms: 0 };
135
135
  for (const e of entries || []) {
136
136
  if (e.kind === 'call') ev.calls++;
137
137
  else if (e.kind === 'result') { ev.results++; if (e.error) ev.resultErrors++; }
@@ -139,7 +139,7 @@ export function workLogEvidence(entries) {
139
139
  else if (e.kind === 'text') ev.texts++;
140
140
  else if (e.kind === 'attempt') ev.attempts++;
141
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++; }
142
+ else if (e.kind === 'post') { ev.posts++; if (e.post?.kind === 'finding') ev.findings++; if (e.post?.kind === 'request') ev.requests++; if (e.post?.status === 'approved') ev.decided.approved++; if (e.post?.status === 'rejected') ev.decided.rejected++; }
143
143
  else if (e.kind === 'end') { ev.status = e.status; ev.ms = e.ms; ev.findings = Math.max(ev.findings, e.findings || 0); }
144
144
  }
145
145
  return ev;
package/tool-need.js CHANGED
@@ -94,3 +94,31 @@ export function toolNeedFor({ request = null, signals = null, attachments = [],
94
94
 
95
95
  return { tools: false, why: 'a greeting — nothing to look up' };
96
96
  }
97
+
98
+ // ── Which GRANTS a task's wording calls for (F8 §15.2) ──────────────────────────────────
99
+ //
100
+ // A team member holding `web` that answers a "latest price" question from memory has not
101
+ // done the task. This is the deterministic half of the tool-choice guard: read the task's
102
+ // text for the vocabulary that names a source — the web, the person's own history, attached
103
+ // material — and return the grants that vocabulary fits. The runner nudges a member ONCE
104
+ // when it ends with zero calls while holding one of these. Conservative by construction:
105
+ // nothing here fires on a task that could plausibly be answered from what the model knows.
106
+ const NEEDS = Object.freeze([
107
+ ['web', /\b(search|google|look ?up|latest|current|recent|today|this (week|month|year)|news|price|prices|pricing|cost of|quote|stock|market|website|url|online|web|docs?umentation|release notes|changelog|versions?)\b/i],
108
+ ['history', /\b(our (meeting|call|notes?|chats?|conversation)|what (did|was) (we|i)|we (decided|agreed|discussed|said)|in (my|our) (notes?|meetings?|history|chats?)|past (chats?|meetings?|notes?)|earlier (meeting|conversation|chat|note)|transcript|standup|retro)\b/i],
109
+ ['data', /\b(attached|attachment|this (page|document|file|pdf|spreadsheet|sheet)|the (document|file|pdf|spreadsheet) (above|provided|attached))\b/i],
110
+ ]);
111
+
112
+ /**
113
+ * The grants a task's text calls for, in the order they fit: `['web']`, `['history', 'web']`,
114
+ * `[]`. `held` narrows to what the role actually has, so the caller gets what is BOTH needed
115
+ * and available — an empty list means no nudge.
116
+ */
117
+ export function grantsNeededFor(text, { held = null } = {}) {
118
+ const t = String(text || '');
119
+ if (!t.trim()) return [];
120
+ const out = NEEDS.filter(([, re]) => re.test(t)).map(([g]) => g);
121
+ if (!held) return out;
122
+ const have = new Set((Array.isArray(held) ? held : []).map((g) => String(g).toLowerCase()));
123
+ return out.filter((g) => have.has(g) || (g === 'history' && have.has('data')));
124
+ }