@chatpanel/events 0.92.1 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.js +7 -0
- package/index.js +3 -0
- package/package.json +4 -2
- package/project-run.js +444 -0
- package/team-record.js +18 -2
- package/team-run.js +39 -48
- package/team-tool.js +35 -4
- package/team-trail.js +10 -0
package/agent.js
CHANGED
|
@@ -178,6 +178,13 @@ export function resolveTeam(team, pool = [], { chatModel = null, targetFor = nul
|
|
|
178
178
|
// that is the bridge's id for Claude Code and the pilot's choice — change it on the card.
|
|
179
179
|
|
|
180
180
|
export const STARTER_AGENTS = Object.freeze([
|
|
181
|
+
// The EXECUTIVE holds a goal (project-run.js): it posts the jobs, recruits for each from
|
|
182
|
+
// the pool, runs the recruited as a team, reads what came back, posts the follow-ups, asks
|
|
183
|
+
// the stakeholder where the gate says a person decides, and closes when done-when holds.
|
|
184
|
+
// A person is the stakeholder by default; this is the manager they delegate the running to.
|
|
185
|
+
{ id: 'executive', name: 'Executive', purpose: 'Runs a project: posts the jobs for the goal, recruits from the pool, reads the results, posts follow-ups, asks before spending or changing scope, closes when done-when holds.',
|
|
186
|
+
prompt: 'You are the Executive. You hold one goal and its done-when. Break the goal into jobs a stranger could act on, each naming the skills and tools it needs; prefer jobs that run at the same time and use dependsOn only when one truly needs another\'s result. Read every result against done-when as written — say it holds only when it does on the results as they are, not as they could be. Post follow-up jobs only for what would move done-when. Say plainly what was not found. You never do a job yourself and never create an agent, tool or skill without a person\'s approval.',
|
|
187
|
+
skills: ['planning', 'management'], grants: ['none'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
|
|
181
188
|
{ id: 'architect', name: 'Architect', purpose: 'Reads the docs and the repos; writes the project page and the jobs.',
|
|
182
189
|
prompt: 'You are the Architect. Read the feature doc, ROADMAP.md, naming-revamp.md and architecture-pillars.md before deciding anything. Write the project page (goal, done-when, budget) and post one job per repo that must change, saying which repo and what the guard is. Propose a new agent type only when no one in the pool fits. Never run a shell.',
|
|
183
190
|
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
|
package/index.js
CHANGED
|
@@ -202,6 +202,9 @@ export { validateProject, normalizeProject, defineProject, canTransition as canP
|
|
|
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';
|
|
204
204
|
export { validateGate, normalizeGate, effectiveGate, gateAllows, DEFAULT_GATE, AUTONOMY, HUMAN_FLAGS, CHECKS } from './gate.js';
|
|
205
|
+
// The executive loop (F8 §12.2.5): a goal run as a project — jobs, recruiting, rounds run as
|
|
206
|
+
// teams, the review, follow-ups, done-when — every step on the project record.
|
|
207
|
+
export { runProject, roundJobs, teamForRound, jobResults, roundBudget, parseJobs, parseReview, jobsPrompt, reviewPrompt, PROJECT_JOBS_SCHEMA, PROJECT_REVIEW_SCHEMA, ProjectRunError, EXECUTIVE, MAX_JOBS_PER_ROUND, MAX_ROUNDS, PROJECT_RUN_STATUSES } from './project-run.js';
|
|
205
208
|
export { canonical, sha256, makeEntry, verifyChain, attest, verifyAttested, summarize, fit, adjustSummary, normalizeEngine, engineKey, normalizeScm, SCORECARD_ENTRY_KINDS, ROLE_KINDS, SCORECARD_VERSION, ENGINE_KINDS as RECORD_ENGINE_KINDS } from './scorecard.js';
|
|
206
209
|
export { ENGINE_KINDS as ENGINE_SPEC_KINDS, ROUTE_PREFERS, normalizePolicy, normalizeEngineSpec, validateEngineSpec, engineRef, engineKeyOf, describeEngine, tierOf } from './engine.js';
|
|
207
210
|
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';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The canonical ChatPanel event-log and capability contracts
|
|
3
|
+
"version": "0.94.0",
|
|
4
|
+
"description": "The canonical ChatPanel event-log and capability contracts — 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",
|
|
7
7
|
"exports": {
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"./team-subtask.js": "./team-subtask.js",
|
|
103
103
|
"./scorecard.js": "./scorecard.js",
|
|
104
104
|
"./project.js": "./project.js",
|
|
105
|
+
"./project-run.js": "./project-run.js",
|
|
105
106
|
"./job.js": "./job.js",
|
|
106
107
|
"./gate.js": "./gate.js",
|
|
107
108
|
"./team-plan.js": "./team-plan.js",
|
|
@@ -235,6 +236,7 @@
|
|
|
235
236
|
"team-subtask.js",
|
|
236
237
|
"scorecard.js",
|
|
237
238
|
"project.js",
|
|
239
|
+
"project-run.js",
|
|
238
240
|
"job.js",
|
|
239
241
|
"gate.js",
|
|
240
242
|
"team-plan.js",
|
package/project-run.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// The executive loop — a goal, run as a project (F8 §12.2.5).
|
|
2
|
+
//
|
|
3
|
+
// A person writes the goal; the executive does what a manager does with it: reads the page,
|
|
4
|
+
// posts the first jobs, recruits for each from the pool, runs the recruited pair as a team
|
|
5
|
+
// against the briefs, reads what came back, posts the follow-up jobs the results call for,
|
|
6
|
+
// asks the stakeholder before anything that spends more or changes scope, and closes when
|
|
7
|
+
// done-when holds. Every step is an event on the project record (project.js `foldProject`),
|
|
8
|
+
// so both clients draw the project page live and the loop is RESUMABLE from the record: a
|
|
9
|
+
// job already done is not redone, one already recruited is not recruited again.
|
|
10
|
+
//
|
|
11
|
+
// This module never speaks to a model, never runs a tool and never stores anything. The
|
|
12
|
+
// host injects: `plan(prompt, schema)` — its structured call on the executive's model;
|
|
13
|
+
// `recruit(job)` — its pool through recruit.js `recruitForRun`; `runJobs({ team, request })`
|
|
14
|
+
// — its team runner (team-run.js through the host's own callModel, so a job's work is a run
|
|
15
|
+
// on the board with threads, a work log and a scorecard fact per member); `ask({ type, text,
|
|
16
|
+
// options })` — the stakeholder. What is asked and what is not is the GATE (gate.js): the
|
|
17
|
+
// loop reads it at every step where it would otherwise ask, and asks a person only where
|
|
18
|
+
// the gate says a person decides.
|
|
19
|
+
//
|
|
20
|
+
// Nothing here is created without a decision: an agent proposed for a job nobody fits goes
|
|
21
|
+
// to the person as a `permission` ask before it exists (§7, D-A2).
|
|
22
|
+
|
|
23
|
+
import { defineSchema, describeSchema, coerce } from './structured.js';
|
|
24
|
+
import { GRANT_RE } from './team.js';
|
|
25
|
+
import { normalizeJob, readyJobs, canTransition as jobCanMove } from './job.js';
|
|
26
|
+
import { emptyProjectRecord, foldProject, projectProgress } from './project.js';
|
|
27
|
+
import { effectiveGate, gateAllows } from './gate.js';
|
|
28
|
+
import { carveBudget } from './recruit.js';
|
|
29
|
+
|
|
30
|
+
export const MAX_JOBS_PER_ROUND = 8;
|
|
31
|
+
export const MAX_ROUNDS = 4;
|
|
32
|
+
export const EXECUTIVE = 'executive';
|
|
33
|
+
export const PROJECT_RUN_STATUSES = Object.freeze(['done', 'open', 'waiting', 'stopped', 'over-budget', 'failed']);
|
|
34
|
+
|
|
35
|
+
export class ProjectRunError extends Error {
|
|
36
|
+
constructor(code, message) { super(message); this.name = 'ProjectRunError'; this.code = code; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const clip = (s, n) => String(s || '').trim().slice(0, n);
|
|
40
|
+
const slug = (s) => String(s || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 48);
|
|
41
|
+
|
|
42
|
+
// ── What the executive answers in ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const JOB_FIELDS = {
|
|
45
|
+
id: { type: 'string', required: true, max: 48, describe: 'a short id like j1' },
|
|
46
|
+
title: { type: 'string', required: true, max: 120 },
|
|
47
|
+
brief: { type: 'string', required: true, max: 2000, describe: 'what to do and what done looks like — enough for someone who has not read the goal' },
|
|
48
|
+
skills: { type: 'string[]', maxItems: 6, describe: 'skills the job needs, by name (finance, review, …) — used to pick who takes it' },
|
|
49
|
+
grants: { type: 'string[]', maxItems: 6, describe: 'tools it needs: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr — or none' },
|
|
50
|
+
dependsOn: { type: 'string[]', maxItems: 6, describe: 'job ids whose results this one needs' },
|
|
51
|
+
why: { type: 'string', max: 200, describe: 'why this job, in a few words' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The first jobs for a goal, and the follow-ups after a round. */
|
|
55
|
+
export const PROJECT_JOBS_SCHEMA = defineSchema({
|
|
56
|
+
name: 'project_jobs',
|
|
57
|
+
purpose: 'the jobs to post on a project for its goal',
|
|
58
|
+
fields: {
|
|
59
|
+
jobs: { type: 'object[]', required: true, maxItems: MAX_JOBS_PER_ROUND, describe: 'independent where possible; a job that needs another\'s result names it in dependsOn', fields: JOB_FIELDS },
|
|
60
|
+
note: { type: 'string', max: 600, describe: 'what you decided and why, for the project page' },
|
|
61
|
+
},
|
|
62
|
+
nothing: { jobs: [], note: '' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** After a round: does done-when hold, what is the report, what is still needed. */
|
|
66
|
+
export const PROJECT_REVIEW_SCHEMA = defineSchema({
|
|
67
|
+
name: 'project_review',
|
|
68
|
+
purpose: 'the executive\'s reading of a round of jobs against the project\'s goal and done-when',
|
|
69
|
+
fields: {
|
|
70
|
+
done: { type: 'boolean', required: true, describe: 'true only when done-when holds on the results as they are' },
|
|
71
|
+
report: { type: 'string', required: true, max: 6000, describe: 'the project\'s report so far: what was established, with the job it came from; what was not' },
|
|
72
|
+
followUps: { type: 'object[]', maxItems: MAX_JOBS_PER_ROUND, describe: 'the jobs still needed for done-when to hold — empty when it holds or nothing more would help', fields: JOB_FIELDS },
|
|
73
|
+
why: { type: 'string', max: 400, describe: 'why done or not, in a few words' },
|
|
74
|
+
},
|
|
75
|
+
nothing: { done: false, report: '', followUps: [], why: '' },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const poolLines = (pool) => (pool || []).filter((a) => a && a.id && a.enabled !== false).slice(0, 40)
|
|
79
|
+
.map((a) => `- ${a.id}: ${a.name || a.id}${a.purpose ? ` — ${clip(a.purpose, 140)}` : ''}${a.skills?.length ? ` (skills: ${a.skills.join(', ')})` : ''}${a.grants?.length ? ` (tools: ${a.grants.join(', ')})` : ''}`).join('\n');
|
|
80
|
+
|
|
81
|
+
const jobLines = (jobs) => (jobs || []).map((j) => `- ${j.id} [${j.status}]: ${j.title}${j.result?.text ? ` — result: ${clip(j.result.text, 400)}` : ''}`).join('\n');
|
|
82
|
+
|
|
83
|
+
/** The executive's first instruction: the goal, done-when, the pool, the shape. */
|
|
84
|
+
export function jobsPrompt(project, { pool = [], record = null } = {}) {
|
|
85
|
+
const had = (record?.jobs || []).length ? `\nJobs already on the project (do not repeat them):\n${jobLines(record.jobs)}\n` : '';
|
|
86
|
+
return [
|
|
87
|
+
`You are the executive of the project "${project.title}". Post the jobs that get it to done — between 1 and ${MAX_JOBS_PER_ROUND}, independent where possible, each with a brief a stranger could act on.`,
|
|
88
|
+
'',
|
|
89
|
+
`Goal: ${project.goal}`,
|
|
90
|
+
project.doneWhen ? `Done when: ${project.doneWhen}` : '',
|
|
91
|
+
project.budget ? `Budget for the whole project: ${Object.entries(project.budget).map(([k, v]) => `${k} ${v}`).join(', ')}` : '',
|
|
92
|
+
had,
|
|
93
|
+
'Agents in the pool that may take a job (name the skills and tools a job needs; the fit picks who takes it):',
|
|
94
|
+
poolLines(pool) || '- (nobody yet — name the skills and tools anyway; a job nobody fits is proposed as a new agent)',
|
|
95
|
+
'',
|
|
96
|
+
describeSchema(PROJECT_JOBS_SCHEMA),
|
|
97
|
+
].filter((l) => l !== null && l !== undefined).join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The executive's reading of a round: the goal, done-when, every job's result, the shape. */
|
|
101
|
+
export function reviewPrompt(project, record, { round = 1 } = {}) {
|
|
102
|
+
return [
|
|
103
|
+
`You are the executive of the project "${project.title}". Round ${round} of jobs has finished. Read the results against the goal and done-when. Write the report so far. Say whether done-when HOLDS on these results — not whether it could with more work. If it does not hold and more work would help, post the follow-up jobs (at most ${MAX_JOBS_PER_ROUND}); if nothing more would help, post none and say why.`,
|
|
104
|
+
'',
|
|
105
|
+
`Goal: ${project.goal}`,
|
|
106
|
+
project.doneWhen ? `Done when: ${project.doneWhen}` : 'Done when: (not stated — hold the results to the goal as written)',
|
|
107
|
+
'',
|
|
108
|
+
'Jobs and their results:',
|
|
109
|
+
jobLines(record?.jobs || []),
|
|
110
|
+
record?.report?.text ? `\nThe report before this round:\n${clip(record.report.text, 3000)}` : '',
|
|
111
|
+
'',
|
|
112
|
+
describeSchema(PROJECT_REVIEW_SCHEMA),
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The executive's answer (text or an already-shaped value) as normalized jobs on the project.
|
|
118
|
+
* Ids the executive reused are suffixed; grants it made up are dropped; a dependency on a job
|
|
119
|
+
* that is not on the project (or on itself) is dropped rather than left dangling.
|
|
120
|
+
*/
|
|
121
|
+
export function parseJobs(answer, project, { existing = [], now = Date.now(), by = EXECUTIVE, schema = PROJECT_JOBS_SCHEMA, field = 'jobs' } = {}) {
|
|
122
|
+
const value = typeof answer === 'string' ? coerce(answer, schema)?.value : answer;
|
|
123
|
+
const raw = Array.isArray(value?.[field]) ? value[field] : [];
|
|
124
|
+
const taken = new Set((existing || []).map((j) => j.id));
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [i, j] of raw.entries()) {
|
|
127
|
+
if (!j || !clip(j.title, 200) || !clip(j.brief, 8000)) continue;
|
|
128
|
+
let id = slug(j.id) || `j${i + 1}`;
|
|
129
|
+
if (!/^[a-z]/.test(id)) id = `j-${id}`;
|
|
130
|
+
let n = 1;
|
|
131
|
+
const base = id;
|
|
132
|
+
while (taken.has(id)) { n += 1; id = `${base}-${n}`; }
|
|
133
|
+
taken.add(id);
|
|
134
|
+
const grants = [...new Set((Array.isArray(j.grants) ? j.grants : []).map((g) => String(g).trim().toLowerCase()).filter((g) => GRANT_RE.test(g) && g !== 'none'))].slice(0, 6);
|
|
135
|
+
try {
|
|
136
|
+
out.push(normalizeJob({
|
|
137
|
+
id, projectId: project.id, title: j.title, brief: j.brief,
|
|
138
|
+
needs: { skills: j.skills, grants, tools: [] },
|
|
139
|
+
dependsOn: Array.isArray(j.dependsOn) ? j.dependsOn.map((d) => slug(d)) : [],
|
|
140
|
+
postedBy: by, postedAt: now, status: 'open',
|
|
141
|
+
...(j.why ? { origin: { kind: 'executive', why: clip(j.why, 200) } } : {}),
|
|
142
|
+
}));
|
|
143
|
+
} catch { /* a job the form refuses is not posted */ }
|
|
144
|
+
if (out.length >= MAX_JOBS_PER_ROUND) break;
|
|
145
|
+
}
|
|
146
|
+
const ids = new Set([...out.map((j) => j.id), ...(existing || []).map((j) => j.id)]);
|
|
147
|
+
for (const j of out) j.dependsOn = j.dependsOn.filter((d) => ids.has(d) && d !== j.id);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The review, coerced: `{ done, report, followUps: jobs[], why }`. */
|
|
152
|
+
export function parseReview(answer, project, { existing = [], now = Date.now() } = {}) {
|
|
153
|
+
const value = typeof answer === 'string' ? coerce(answer, PROJECT_REVIEW_SCHEMA)?.value : answer;
|
|
154
|
+
if (!value || typeof value !== 'object') return null;
|
|
155
|
+
return {
|
|
156
|
+
done: value.done === true,
|
|
157
|
+
report: clip(value.report, 6000),
|
|
158
|
+
why: clip(value.why, 400),
|
|
159
|
+
followUps: parseJobs(value, project, { existing, now, field: 'followUps', schema: PROJECT_REVIEW_SCHEMA }),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The team a round runs as: one role per recruited job (the role id IS the job id, so two
|
|
165
|
+
* jobs taken by the same agent are two members), the job's dependencies as the role's, a
|
|
166
|
+
* fixed plan (one task per role, the brief as the task), the members' work side by side —
|
|
167
|
+
* the executive reads it, so no judge. The budget is the round's carve.
|
|
168
|
+
*/
|
|
169
|
+
export function teamForRound(project, recruited, { budget, round = 1 } = {}) {
|
|
170
|
+
const ids = new Set(recruited.map((r) => r.job.id));
|
|
171
|
+
return {
|
|
172
|
+
name: project.id,
|
|
173
|
+
description: `${project.title} — round ${round}`,
|
|
174
|
+
plan: 'fixed', merge: 'concat',
|
|
175
|
+
roles: recruited.map(({ job, role }) => ({
|
|
176
|
+
...role,
|
|
177
|
+
id: job.id,
|
|
178
|
+
name: `${role.name || role.agent || job.id} · ${job.title}`.slice(0, 120),
|
|
179
|
+
dependsOn: (job.dependsOn || []).filter((d) => ids.has(d)),
|
|
180
|
+
job: job.id,
|
|
181
|
+
})),
|
|
182
|
+
budget,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What a round's run says about each job: the task with the job's id, its status and text. */
|
|
187
|
+
export function jobResults(run, jobs, { now = Date.now() } = {}) {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const job of jobs) {
|
|
190
|
+
const task = (run?.tasks || []).find((t) => t.id === `t_${job.id}` || t.role === job.id);
|
|
191
|
+
if (!task) { out.push({ id: job.id, status: 'failed', result: { text: 'the run never started this job', by: 'runner', at: now } }); continue; }
|
|
192
|
+
const ok = task.status === 'ok';
|
|
193
|
+
const findings = (task.findings || []).map((f) => `- ${f.text}${f.refs?.length ? ` (${f.refs.join(', ')})` : ''}`).join('\n');
|
|
194
|
+
out.push({
|
|
195
|
+
id: job.id,
|
|
196
|
+
status: ok ? 'done' : 'failed',
|
|
197
|
+
result: { text: clip(ok ? (findings || task.text) : (task.error || task.status || 'failed'), 8000), by: task.role || job.id, at: now, refs: [`run:${run.runId}`, ...(task.findings || []).flatMap((f) => f.refs || []).slice(0, 8)] },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The jobs a round runs: every open job whose dependencies are done — and, with them, every
|
|
205
|
+
* open job whose dependencies are done OR in this round. A chain (facts → memo) is one round
|
|
206
|
+
* and one team; the runner's barriers order it (team-run.js), and the executive reviews the
|
|
207
|
+
* whole chain, not half of it.
|
|
208
|
+
*/
|
|
209
|
+
export function roundJobs(jobs) {
|
|
210
|
+
const wave = readyJobs(jobs);
|
|
211
|
+
const inWave = new Set(wave.map((j) => j.id));
|
|
212
|
+
const byId = new Map((jobs || []).map((j) => [j.id, j]));
|
|
213
|
+
for (;;) {
|
|
214
|
+
const more = (jobs || []).filter((j) => j.status === 'open' && !inWave.has(j.id) && (j.dependsOn || []).every((d) => byId.get(d)?.status === 'done' || inWave.has(d)));
|
|
215
|
+
if (!more.length) break;
|
|
216
|
+
for (const j of more) { wave.push(j); inWave.add(j.id); }
|
|
217
|
+
}
|
|
218
|
+
return wave;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The round's budget: what the project has left, split over the jobs that run, never under a floor a run can start with. */
|
|
222
|
+
export function roundBudget(project, record, jobs) {
|
|
223
|
+
const cap = project.budget || {};
|
|
224
|
+
const spent = record?.spend || {};
|
|
225
|
+
const out = {};
|
|
226
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
227
|
+
if (!(Number(cap[k]) > 0)) continue;
|
|
228
|
+
const left = Math.max(0, Number(cap[k]) - (Number(spent[k]) || 0));
|
|
229
|
+
if (left > 0) out[k] = k === 'usd' ? Math.round(left * 100) / 100 : Math.floor(left);
|
|
230
|
+
}
|
|
231
|
+
// A job's own budget caps its share; the round is the sum of the shares, within what is left.
|
|
232
|
+
const shares = jobs.map((j) => carveBudget(j, record)).filter(Boolean);
|
|
233
|
+
if (shares.length === jobs.length) {
|
|
234
|
+
for (const k of Object.keys(out)) {
|
|
235
|
+
const sum = shares.reduce((n, s) => n + (Number(s[k]) || 0), 0);
|
|
236
|
+
if (sum > 0) out[k] = Math.min(out[k], sum);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return Object.keys(out).length ? out : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── The loop ─────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param project the page (project.js normalizeProject)
|
|
246
|
+
* @param record the project record so far (foldProject), for a resume; null starts fresh
|
|
247
|
+
* @param pool the agent cards the executive may recruit from (read for the prompt; the
|
|
248
|
+
* host's `recruit` does the pass)
|
|
249
|
+
* @param gate the org's gate (gate.js); the project's own partial gate overrides it
|
|
250
|
+
* @param executive the agent id that runs the loop — on every decision as `by`
|
|
251
|
+
* @param plan `async (prompt, schema) => value | text | null` — the host's structured call
|
|
252
|
+
* @param recruit `async (job, { projectId, create? }) => { role, agentId, engine, why, fit } |
|
|
253
|
+
* { proposal, why } | null` — the host's pool (recruit.js recruitForRun)
|
|
254
|
+
* @param runJobs `async ({ team, request, projectId, round }) => run result` — the host's
|
|
255
|
+
* team runner; `team` is teamForRound's
|
|
256
|
+
* @param ask `async ({ type, text, options }) => { text, by } | null` — the stakeholder;
|
|
257
|
+
* absent, every ask is answered "no" and the loop stops where it would ask
|
|
258
|
+
* @param emit `(type, payload)` — every project-record event, in order; the host lands
|
|
259
|
+
* them on the gateway's project record (and this call folds them too)
|
|
260
|
+
* @param maxRounds rounds of post → recruit → run → review before the loop stops and asks
|
|
261
|
+
*/
|
|
262
|
+
export async function runProject({
|
|
263
|
+
project, record = null, pool = [], gate = null, executive = EXECUTIVE,
|
|
264
|
+
plan, recruit = null, runJobs, ask = null, emit = () => {},
|
|
265
|
+
now = () => Date.now(), signal = null, maxRounds = MAX_ROUNDS,
|
|
266
|
+
} = {}) {
|
|
267
|
+
if (!project?.id || !project.goal) throw new ProjectRunError('BAD_PROJECT', 'a project with an id and a goal is required');
|
|
268
|
+
if (typeof plan !== 'function') throw new ProjectRunError('BAD_RUN', 'plan required');
|
|
269
|
+
if (typeof runJobs !== 'function') throw new ProjectRunError('BAD_RUN', 'runJobs required');
|
|
270
|
+
const rec = record && record.id === project.id ? record : emptyProjectRecord({ id: project.id, now: now() });
|
|
271
|
+
if (!rec.page) foldProject(rec, { type: 'project.created', at: now(), payload: { project } });
|
|
272
|
+
const g = effectiveGate(gate, project.gate || null);
|
|
273
|
+
const stopped = () => !!signal?.aborted;
|
|
274
|
+
const say = (type, payload = {}) => { const ev = { type, at: now(), ...payload }; foldProject(rec, { type, at: ev.at, payload }); emit(type, { projectId: project.id, ...ev }); };
|
|
275
|
+
const decide = (kind, text, refs = []) => say('project.decision', { by: executive, kind, text: clip(text, 2000), refs });
|
|
276
|
+
// The recruiter's own events (recruit.js recruitEvents: evaluating → recruited | open + a
|
|
277
|
+
// proposal decision) land on the record as they are.
|
|
278
|
+
const land = (events) => { for (const ev of events || []) { if (!ev?.type) continue; const { type, at: _at, ...payload } = ev; say(type, payload); } };
|
|
279
|
+
const runs = [];
|
|
280
|
+
let rounds = 0;
|
|
281
|
+
const finish = (status, extra = {}) => ({ projectId: project.id, status, rounds, jobs: rec.jobs.map((j) => ({ id: j.id, title: j.title, status: j.status, ...(j.recruited ? { agentId: j.recruited.agentId } : {}), ...(j.runId ? { runId: j.runId } : {}) })), runs, report: rec.report?.text || '', spend: rec.spend, progress: projectProgress(rec), record: rec, ...extra });
|
|
282
|
+
|
|
283
|
+
/** The stakeholder, where the gate says a person decides; recorded either way. */
|
|
284
|
+
const askPerson = async ({ type, text, options }) => {
|
|
285
|
+
decide('ask', `${text}${options?.length ? ` [${options.join(' / ')}]` : ''}`);
|
|
286
|
+
if (typeof ask !== 'function') { decide('answer', 'nobody to ask — taken as no'); return null; }
|
|
287
|
+
let a = null;
|
|
288
|
+
try { a = await ask({ type, text, options }); } catch { a = null; }
|
|
289
|
+
decide('answer', a ? `${a.by || 'person'}: ${a.text}` : 'no answer');
|
|
290
|
+
return a;
|
|
291
|
+
};
|
|
292
|
+
const yes = (a) => !!a && /^(yes|ok|allow|approve|go|post|recruit|create|raise|close|done|continue|run)/i.test(String(a.text || '').trim());
|
|
293
|
+
|
|
294
|
+
/** The executive's structured call, either shape, never a throw. */
|
|
295
|
+
const structured = async (prompt, schema) => { try { const v = await plan(prompt, schema); return typeof v === 'string' ? coerce(v, schema)?.value ?? null : (v && typeof v === 'object' ? v : null); } catch { return null; } };
|
|
296
|
+
|
|
297
|
+
/** Post jobs — the person's say first when the gate wants it. */
|
|
298
|
+
const post = async (jobs, { what }) => {
|
|
299
|
+
if (!jobs.length) return [];
|
|
300
|
+
const lines = jobs.map((j) => `• ${j.id}: ${j.title}${j.needs?.skills?.length ? ` (${j.needs.skills.join(', ')})` : ''}${j.dependsOn?.length ? ` — after ${j.dependsOn.join(', ')}` : ''}`).join('\n');
|
|
301
|
+
// Scope is the stakeholder's (D-A3): the first jobs and any follow-up are posted with
|
|
302
|
+
// their say unless the gate lets the executive recruit on its own.
|
|
303
|
+
if (!gateAllows(g, 'recruit').allowed) {
|
|
304
|
+
const a = await askPerson({ type: 'direction', text: `${what} — post ${jobs.length} job${jobs.length === 1 ? '' : 's'}?\n${lines}`, options: ['Post them', 'Stop here'] });
|
|
305
|
+
if (!yes(a)) return null;
|
|
306
|
+
} else decide('plan', `${what}: ${jobs.length} job${jobs.length === 1 ? '' : 's'} posted\n${lines}`);
|
|
307
|
+
for (const j of jobs) say('job.posted', { job: j, by: executive });
|
|
308
|
+
return jobs;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
if (rec.status === 'draft') say('project.status', { status: 'open', by: executive });
|
|
312
|
+
if (['done', 'closed'].includes(rec.status)) return finish(rec.status === 'done' ? 'done' : 'stopped', { why: `the project is ${rec.status}` });
|
|
313
|
+
// A RESUME: a job the last loop left recruited or running (its process died) is open
|
|
314
|
+
// again — recruited again, run again; one it finished is not. The record is the checkpoint.
|
|
315
|
+
if (record) {
|
|
316
|
+
const stuck = rec.jobs.filter((j) => ['evaluating', 'recruited', 'in-progress'].includes(j.status));
|
|
317
|
+
for (const j of stuck) say('job.updated', { job: { id: j.id, status: 'open' }, by: executive });
|
|
318
|
+
if (stuck.length) decide('resume', `resumed: ${stuck.map((j) => j.id).join(', ')} back to open (left ${stuck.map((j) => j.status).join(', ')} by the last loop)`);
|
|
319
|
+
else if (rec.jobs.length) decide('resume', `resumed with ${rec.jobs.filter((j) => j.status === 'done').length} of ${rec.jobs.length} jobs done`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── 1. the first jobs (skipped on a resume that already has some) ──
|
|
323
|
+
if (!rec.jobs.length) {
|
|
324
|
+
const value = await structured(jobsPrompt(project, { pool, record: rec }), PROJECT_JOBS_SCHEMA);
|
|
325
|
+
const jobs = parseJobs(value || {}, project, { existing: rec.jobs, now: now(), by: executive });
|
|
326
|
+
if (!jobs.length) {
|
|
327
|
+
decide('plan', 'the executive could not turn the goal into jobs');
|
|
328
|
+
const a = await askPerson({ type: 'direction', text: `I could not turn the goal into jobs. Post one job for the whole goal, or stop?`, options: ['Post one job', 'Stop'] });
|
|
329
|
+
if (!yes(a)) return finish('failed', { why: 'no jobs' });
|
|
330
|
+
jobs.push(normalizeJob({ id: 'j1', projectId: project.id, title: clip(project.title, 200), brief: `${project.goal}${project.doneWhen ? `\n\nDone when: ${project.doneWhen}` : ''}`, needs: { skills: [], grants: ['data', 'web'], tools: [] }, postedBy: executive, postedAt: now() }));
|
|
331
|
+
} else if (value?.note) decide('plan', value.note);
|
|
332
|
+
const posted = await post(jobs, { what: 'First jobs' });
|
|
333
|
+
if (!posted) return finish('stopped', { why: 'the stakeholder did not post the first jobs' });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── rounds: recruit what is ready → run → fold → review → follow-ups ──
|
|
337
|
+
for (;;) {
|
|
338
|
+
if (stopped()) return finish('stopped');
|
|
339
|
+
if (rounds >= maxRounds) {
|
|
340
|
+
decide('review', `${rounds} rounds run; the loop stops here and asks`);
|
|
341
|
+
const a = await askPerson({ type: 'direction', text: `${rounds} rounds have run and done-when does not hold yet. Run another round, or stop with the report so far?`, options: ['Run another round', 'Stop here'] });
|
|
342
|
+
if (!yes(a)) return finish('open', { why: 'rounds exhausted' });
|
|
343
|
+
maxRounds += 1;
|
|
344
|
+
}
|
|
345
|
+
const ready = roundJobs(rec.jobs);
|
|
346
|
+
const stillOpen = rec.jobs.filter((j) => ['open', 'evaluating', 'recruited', 'in-progress'].includes(j.status));
|
|
347
|
+
if (!ready.length) {
|
|
348
|
+
if (!stillOpen.length) return finish(rec.status === 'done' ? 'done' : 'open', { why: 'nothing left to run' });
|
|
349
|
+
// Open jobs whose dependencies failed: they cannot run.
|
|
350
|
+
decide('review', `${stillOpen.length} job${stillOpen.length === 1 ? '' : 's'} cannot run: a dependency failed`);
|
|
351
|
+
for (const j of stillOpen) say('job.updated', { job: { id: j.id, status: 'failed', result: { text: 'a job it depends on failed', by: executive, at: now() } }, by: executive });
|
|
352
|
+
return finish('failed', { why: 'dependencies failed' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── 2. recruit ──
|
|
356
|
+
const recruited = [];
|
|
357
|
+
for (const job of ready) {
|
|
358
|
+
if (stopped()) return finish('stopped');
|
|
359
|
+
if (typeof recruit !== 'function') { say('job.updated', { job: { id: job.id, status: 'failed', result: { text: 'this host has no pool to recruit from', by: executive, at: now() } }, by: executive }); continue; }
|
|
360
|
+
let r = null;
|
|
361
|
+
try { r = await recruit(job, { projectId: project.id }); } catch (e) { decide('recruit', `recruiting for "${job.title}" failed: ${clip(e?.message || e, 200)}`, [`job:${job.id}`]); }
|
|
362
|
+
// The recruiter's own events (evaluating → recruited | open + proposal) land on the record.
|
|
363
|
+
land(r?.events);
|
|
364
|
+
if (!r?.role && r?.proposal) {
|
|
365
|
+
// Nobody fits: the agent the job describes, proposed — created only on the person's say.
|
|
366
|
+
const card = r.proposal;
|
|
367
|
+
say('project.decision', { by: executive, kind: 'proposal', text: `No one in the pool fits "${job.title}"${r.why ? ` — ${r.why}` : ''}. Proposed: ${card.name}${card.skills?.length ? ` — skills ${card.skills.join(', ')}` : ''}${card.grants?.length ? `; grants ${card.grants.join(', ')}` : ''}.`, refs: [`job:${job.id}`], proposal: { kind: 'agent', agent: card, jobId: job.id } });
|
|
368
|
+
const allowed = gateAllows(g, 'newAgent').allowed;
|
|
369
|
+
const a = allowed ? { text: 'Create it', by: 'gate' } : await askPerson({ type: 'permission', text: `No one fits "${job.title}". Create the agent "${card.name}" (${[card.skills?.length ? `skills ${card.skills.join(', ')}` : '', card.grants?.length ? `grants ${card.grants.join(', ')}` : ''].filter(Boolean).join('; ') || 'no particular skills'}) and give it the job?`, options: ['Create it', 'Skip'] });
|
|
370
|
+
if (yes(a)) {
|
|
371
|
+
try { r = await recruit(job, { projectId: project.id, create: card }); } catch (e) { decide('recruit', `creating "${card.name}" failed: ${clip(e?.message || e, 200)}`, [`job:${job.id}`]); r = null; }
|
|
372
|
+
land(r?.events);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!r?.role) {
|
|
376
|
+
say('job.updated', { job: { id: job.id, status: 'failed', result: { text: `nobody took it${r?.why ? ` — ${r.why}` : ''}`, by: executive, at: now() } }, by: executive });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// A recruited job is recorded so a resume does not recruit it again (the recruiter's
|
|
380
|
+
// events say so when it landed them; a bare role is recorded here).
|
|
381
|
+
const nowJob = rec.jobs.find((j) => j.id === job.id);
|
|
382
|
+
if (nowJob && nowJob.status !== 'recruited' && jobCanMove(nowJob.status, 'recruited')) say('job.updated', { job: { id: job.id, status: 'recruited', recruited: { agentId: r.agentId || r.role.agent || r.role.id, ...(r.engine ? { engine: r.engine } : {}), by: 'fit', at: now(), ...(r.why ? { why: clip(r.why, 600) } : {}) } }, by: executive });
|
|
383
|
+
decide('recruit', `${r.agentId || r.role.agent || r.role.id} took "${job.title}"${r.why ? ` — ${r.why}` : ''}`, [`job:${job.id}`]);
|
|
384
|
+
recruited.push({ job: rec.jobs.find((j) => j.id === job.id) || job, role: r.role });
|
|
385
|
+
}
|
|
386
|
+
if (!recruited.length) {
|
|
387
|
+
if (rec.jobs.some((j) => ['open', 'recruited'].includes(j.status))) continue; // dependents of a failed job: the next pass fails them
|
|
388
|
+
return finish('failed', { why: 'nobody took any job' });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── 3. run the round as one team ──
|
|
392
|
+
const jobs = recruited.map((x) => x.job);
|
|
393
|
+
const budget = roundBudget(project, rec, jobs);
|
|
394
|
+
if (!budget) {
|
|
395
|
+
decide('review', 'the project has no budget left for another round');
|
|
396
|
+
const allowed = gateAllows(g, 'budgetRaise').allowed;
|
|
397
|
+
const a = allowed ? null : await askPerson({ type: 'budget', text: `The project has spent its budget (${Object.entries(rec.spend).filter(([, v]) => v).map(([k, v]) => `${k} ${v}`).join(', ')}) with ${jobs.length} job${jobs.length === 1 ? '' : 's'} recruited and not run. Raise it by half, or stop here with the report so far?`, options: ['Raise by half', 'Stop here'] });
|
|
398
|
+
if (!allowed && !yes(a)) return finish('over-budget');
|
|
399
|
+
for (const k of Object.keys(project.budget || {})) project.budget[k] = Math.ceil(project.budget[k] * 1.5);
|
|
400
|
+
say('project.updated', { project: { ...project } });
|
|
401
|
+
decide('answer', `budget raised by half: ${Object.entries(project.budget).map(([k, v]) => `${k} ${v}`).join(', ')}`);
|
|
402
|
+
}
|
|
403
|
+
rounds += 1;
|
|
404
|
+
const team = teamForRound(project, recruited, { budget: roundBudget(project, rec, jobs), round: rounds });
|
|
405
|
+
for (const j of jobs) say('job.updated', { job: { id: j.id, status: 'in-progress' }, by: executive });
|
|
406
|
+
decide('run', `round ${rounds}: ${jobs.map((j) => j.id).join(', ')} run as one team (${Object.entries(team.budget || {}).map(([k, v]) => `${k} ${v}`).join(', ')})`);
|
|
407
|
+
let run = null;
|
|
408
|
+
try {
|
|
409
|
+
run = await runJobs({ team, request: [project.goal, project.doneWhen ? `Done when: ${project.doneWhen}` : ''].filter(Boolean).join('\n'), projectId: project.id, round: rounds, jobs });
|
|
410
|
+
} catch (e) {
|
|
411
|
+
decide('run', `round ${rounds} failed to run: ${clip(e?.message || e, 300)}`);
|
|
412
|
+
}
|
|
413
|
+
if (run?.runId) {
|
|
414
|
+
runs.push({ runId: run.runId, round: rounds, status: run.status });
|
|
415
|
+
for (const j of jobs) say('run.linked', { runId: run.runId, jobId: j.id });
|
|
416
|
+
if (run.usage?.spent) say('run.spent', { runId: run.runId, spent: { ...run.usage.spent, ms: Number(run.usage.spent.ms) || Math.max(0, (run.endedAt || now()) - (run.startedAt || now())) } });
|
|
417
|
+
}
|
|
418
|
+
for (const res of jobResults(run, jobs, { now: now() })) say('job.updated', { job: { ...res, ...(run?.runId ? { runId: run.runId } : {}) }, by: executive });
|
|
419
|
+
if (run && ['stopped', 'waiting'].includes(run.status)) return finish(run.status, { why: `round ${rounds} ${run.status}` });
|
|
420
|
+
if (stopped()) return finish('stopped');
|
|
421
|
+
|
|
422
|
+
// ── 4. review: done-when, the report, the follow-ups ──
|
|
423
|
+
const value = await structured(reviewPrompt(project, rec, { round: rounds }), PROJECT_REVIEW_SCHEMA);
|
|
424
|
+
const review = parseReview(value || {}, project, { existing: rec.jobs, now: now() });
|
|
425
|
+
if (review?.report) say('project.report', { text: review.report, by: executive });
|
|
426
|
+
else say('project.report', { text: rec.jobs.map((j) => `## ${j.title} (${j.status})\n${j.result?.text || ''}`).join('\n\n'), by: 'concat' });
|
|
427
|
+
decide('review', review ? `${review.done ? 'done-when holds' : 'done-when does not hold yet'}${review.why ? ` — ${review.why}` : ''}${review.followUps.length ? `; ${review.followUps.length} follow-up${review.followUps.length === 1 ? '' : 's'}` : ''}` : 'the executive did not answer the review; the report is the results as they are');
|
|
428
|
+
if (review?.done) {
|
|
429
|
+
// Closing is the stakeholder's (project.js: done-when held, a person closed it) —
|
|
430
|
+
// unless the gate lets the executive write back on its own.
|
|
431
|
+
const allowed = gateAllows(g, 'writeBack').allowed;
|
|
432
|
+
const a = allowed ? { text: 'Close as done', by: 'gate' } : await askPerson({ type: 'permission', text: `Done-when holds${review.why ? ` — ${review.why}` : ''}. Close the project as done?`, options: ['Close as done', 'Keep it open'] });
|
|
433
|
+
if (yes(a)) { say('project.status', { status: 'done', by: a.by || 'person' }); return finish('done'); }
|
|
434
|
+
return finish('open', { why: 'done-when holds; left open by the stakeholder' });
|
|
435
|
+
}
|
|
436
|
+
if (!review?.followUps?.length) {
|
|
437
|
+
const left = readyJobs(rec.jobs);
|
|
438
|
+
if (left.length) continue; // jobs queued behind this round run next
|
|
439
|
+
return finish('open', { why: review ? 'nothing more would help' : 'no review' });
|
|
440
|
+
}
|
|
441
|
+
const posted = await post(review.followUps, { what: `After round ${rounds}` });
|
|
442
|
+
if (!posted) return finish('open', { why: 'follow-ups not posted' });
|
|
443
|
+
}
|
|
444
|
+
}
|
package/team-record.js
CHANGED
|
@@ -35,7 +35,7 @@ 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' : (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 || '' } : {}) }));
|
|
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 || '' } : {}), ...(t.kind ? { kind: t.kind } : {}) }));
|
|
39
39
|
run.status = 'running';
|
|
40
40
|
break;
|
|
41
41
|
// A SUB-TASK (§15.2): requested by a member mid-run, it joins the plan under its parent;
|
|
@@ -47,13 +47,29 @@ export function foldRun(run, ev) {
|
|
|
47
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
48
|
}
|
|
49
49
|
break;
|
|
50
|
+
// A TASK ADDED AFTER THE PLAN — the merge (the judge's task, opened when the members are
|
|
51
|
+
// done). It joins the plan so a resume carries it and the board draws its thread and log.
|
|
52
|
+
case 'task.added':
|
|
53
|
+
if (p.taskId && !taskOf(run, p.taskId)) {
|
|
54
|
+
const task = { id: p.taskId, role: p.role || null, title: p.title || p.taskId, dependsOn: Array.isArray(p.dependsOn) ? p.dependsOn : [], ...(p.kind ? { kind: p.kind } : {}) };
|
|
55
|
+
if (run.plan) run.plan.tasks = [...(run.plan.tasks || []), task];
|
|
56
|
+
run.tasks.push({ id: task.id, role: task.role, title: task.title, status: 'pending', findings: 0, ...(p.kind ? { kind: p.kind } : {}) });
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
50
59
|
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
60
|
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
61
|
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
62
|
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
63
|
case 'task.nudged': { const t = taskOf(run, p.taskId); if (t) t.nudged = [...(t.nudged || []), { grants: p.grants || [], at }]; break; }
|
|
55
64
|
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;
|
|
56
|
-
case 'task.started': {
|
|
65
|
+
case 'task.started': {
|
|
66
|
+
// A start the plan never named (a record from a build whose merge was not a task) gets
|
|
67
|
+
// its row here rather than being dropped — the fold never loses a task that ran.
|
|
68
|
+
let t = taskOf(run, p.taskId);
|
|
69
|
+
if (!t && p.taskId) { t = { id: p.taskId, role: p.role || null, title: p.title || p.taskId, status: 'pending', findings: 0, ...(p.taskId === 'merge' ? { kind: 'merge' } : {}) }; run.tasks.push(t); }
|
|
70
|
+
if (t) { t.status = 'running'; t.startedAt = at; t.error = null; }
|
|
71
|
+
run.status = 'running'; break;
|
|
72
|
+
}
|
|
57
73
|
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; }
|
|
58
74
|
case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
|
|
59
75
|
case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }
|
package/team-run.js
CHANGED
|
@@ -256,7 +256,7 @@ export async function runTeam({
|
|
|
256
256
|
const running = new Set();
|
|
257
257
|
const isDone = (tid) => carried.has(tid) || tasksOut.some((x) => x.id === tid);
|
|
258
258
|
// A sub-task with no holder cannot run; it is recorded `unassigned` at the end.
|
|
259
|
-
const ready = () => tasks.filter((x) => x.role && !isDone(x.id) && !running.has(x.id) && (x.dependsOn || []).every(isDone));
|
|
259
|
+
const ready = () => tasks.filter((x) => x.role && x.kind !== 'merge' && !isDone(x.id) && !running.has(x.id) && (x.dependsOn || []).every(isDone));
|
|
260
260
|
let subtasks = tasks.filter((x) => x.parent).length;
|
|
261
261
|
|
|
262
262
|
/**
|
|
@@ -378,7 +378,11 @@ export async function runTeam({
|
|
|
378
378
|
// say it needs among what the role holds. A model attempt that ends with zero calls
|
|
379
379
|
// while holding one of these is nudged once, then may finish.
|
|
380
380
|
const grantsHeld = role?.grants || [];
|
|
381
|
-
|
|
381
|
+
// THE MERGE IS A TASK LIKE THE OTHERS (its row, thread, transcript and work log), with
|
|
382
|
+
// three differences: it reads the whole board, it answers in prose rather than findings,
|
|
383
|
+
// and it is never nudged — "verify a figure with a tool" is its instruction, not a need.
|
|
384
|
+
const isMerge = task.kind === 'merge';
|
|
385
|
+
const mustUse = role?.mode === 'model' && !isMerge ? [...new Set([...grantsNeededFor(task.prompt, { held: grantsHeld }), ...(task.grants || []).filter((g) => grantsHeld.includes(g) || (g.startsWith('mcp:') && grantsHeld.includes('mcp')))])] : [];
|
|
382
386
|
let nudged = false;
|
|
383
387
|
try {
|
|
384
388
|
if (!role) throw new Error(`no role "${task.role}" in the team`);
|
|
@@ -392,12 +396,12 @@ export async function runTeam({
|
|
|
392
396
|
if (!budget.canAfford({ tokens: 0 })) { overBudget = true; throw new Error('over budget'); }
|
|
393
397
|
// What this member reads: the threads of the tasks it depends on, answered asks (its
|
|
394
398
|
// own — a resumed task finds the person's answer here), settled discussions.
|
|
395
|
-
const prior = boardText(board, { taskIds: task.dependsOn?.length ? task.dependsOn : null, role: role.id });
|
|
396
|
-
const prompt = [task.prompt, prior, findingsInstruction()].filter(Boolean).join('\n\n');
|
|
399
|
+
const prior = boardText(board, { taskIds: !isMerge && task.dependsOn?.length ? task.dependsOn : null, role: role.id });
|
|
400
|
+
const prompt = [task.prompt, prior, isMerge ? '' : findingsInstruction()].filter(Boolean).join('\n\n');
|
|
397
401
|
// A host may build a toolset asynchronously (connecting MCP servers takes time).
|
|
398
402
|
// The board tool rides on top of whatever the role was granted.
|
|
399
403
|
const boardTool = boardToolProvider({
|
|
400
|
-
board, role: role.id, taskId: task.id, taskIds: task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
|
|
404
|
+
board, role: role.id, taskId: task.id, taskIds: !isMerge && task.dependsOn?.length ? task.dependsOn : null, askTimeoutMs: askMs, signal: taskAc.signal,
|
|
401
405
|
onAsk: (thread) => { say('task.waiting', { taskId: task.id, role: role.id, threadId: thread.id, text: thread.title }); },
|
|
402
406
|
onRequest: (req) => onRequest(task, role, req),
|
|
403
407
|
waitFor: askMs > 0 ? async (threadId, ms, sig) => {
|
|
@@ -513,7 +517,8 @@ export async function runTeam({
|
|
|
513
517
|
} finally {
|
|
514
518
|
unsubscribe?.();
|
|
515
519
|
}
|
|
516
|
-
|
|
520
|
+
// The merge's answer is the proposal, not a finding of its own (it would double every claim).
|
|
521
|
+
const findings = status === 'ok' && !isMerge ? parseFindings(text, { role: role?.id, taskId: task.id }) : [];
|
|
517
522
|
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role?.id, finding: f }); }
|
|
518
523
|
const thread = board.threadForTask(task.id);
|
|
519
524
|
// The thread says how the task ended. A failure is posted in it as well — a person reading
|
|
@@ -532,7 +537,7 @@ export async function runTeam({
|
|
|
532
537
|
say('task.scored', {
|
|
533
538
|
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',
|
|
534
539
|
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 },
|
|
535
|
-
roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
|
|
540
|
+
roleKind: isMerge ? 'orchestrator' : 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
|
|
536
541
|
refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
|
|
537
542
|
...(task.parent ? { parent: task.parent, requestedBy: task.requestedBy || null } : {}),
|
|
538
543
|
});
|
|
@@ -551,7 +556,7 @@ export async function runTeam({
|
|
|
551
556
|
// Over budget with work left: ask the person ONCE for more, on the board, before stopping.
|
|
552
557
|
if (overBudget && !budgetAsked && !stopped()) {
|
|
553
558
|
budgetAsked = true;
|
|
554
|
-
const left = tasks.filter((x) => x.role && !tasksOut.some((y) => y.id === x.id)).length;
|
|
559
|
+
const left = tasks.filter((x) => x.role && x.kind !== 'merge' && !tasksOut.some((y) => y.id === x.id)).length;
|
|
555
560
|
const spent = budget.snapshot().spent;
|
|
556
561
|
const what = left ? `${left} task${left === 1 ? '' : 's'} and the merge left` : 'only the merge left';
|
|
557
562
|
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'] });
|
|
@@ -560,7 +565,7 @@ export async function runTeam({
|
|
|
560
565
|
}
|
|
561
566
|
// Every planned task gets a row — what never ran is recorded as skipped, not forgotten;
|
|
562
567
|
// a sub-task nobody took is `unassigned`, which is its own kind of undone.
|
|
563
|
-
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 } : {}) });
|
|
568
|
+
for (const task of tasks) if (task.kind !== 'merge' && !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 } : {}) });
|
|
564
569
|
if (stopped()) return finish('stopped');
|
|
565
570
|
if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
|
|
566
571
|
// Over budget is a STOP only when it left work undone; a budget spent on the last task
|
|
@@ -574,46 +579,32 @@ export async function runTeam({
|
|
|
574
579
|
if (!okTasks.length) return finish('failed', { proposal: null });
|
|
575
580
|
if (t.merge === 'judge') {
|
|
576
581
|
const judge = roleOf(t.judge) || strongestRole(t);
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
if (!mm?.model) break;
|
|
596
|
-
if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl], error: judgeErr });
|
|
597
|
-
say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
|
|
598
|
-
judgeModel = mm; judgeRoute = routeOf(mm, judge, { attempt, exclude: excl });
|
|
599
|
-
say('task.routed', { taskId: 'merge', role: judge.id, attempt, ...judgeRoute });
|
|
600
|
-
res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: mm.model, mode: 'model', system: judge.prompt, prompt, tools: judgeTools, signal, onDelta: (delta, full) => say('task.delta', { taskId: 'merge', role: judge.id, delta, text: full }) });
|
|
601
|
-
if (res?.usage) budget.charge(res.usage);
|
|
602
|
-
if (res?.ok && String(res.text || '').trim()) break;
|
|
603
|
-
const err = res?.ok ? 'the model returned no answer' : (res?.error || 'the model did not answer');
|
|
604
|
-
if (stopped() || !isModelUnavailable(err)) break;
|
|
605
|
-
excl.add(mm.model); runExclude.add(mm.model); judgeErr = err;
|
|
606
|
-
}
|
|
607
|
-
const judged = res?.ok && String(res.text || '').trim();
|
|
608
|
-
say(judged ? 'task.done' : 'task.failed', { taskId: 'merge', role: judge.id, status: judged ? 'ok' : 'failed', error: judged ? null : (res?.error || 'the judge did not answer'), findings: 0 });
|
|
609
|
-
const judgeScm = normalizeScm(res?.scm);
|
|
610
|
-
if (judgeScm) say('task.scm', { taskId: 'merge', role: judge.id, ...judgeScm });
|
|
611
|
-
say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: judgeModel?.model || m?.model, engine: judgeRoute?.engine || null, scm: judgeScm || undefined, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
|
|
612
|
-
say('run.usage', { usage: budget.snapshot() });
|
|
613
|
-
proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
|
|
614
|
-
} else {
|
|
615
|
-
proposal = mergeCheap(t, board.all(), tasksOut);
|
|
582
|
+
// The judge's task IS the merge (team-plan.js fixedPlan) — and it is a TASK: a row on the
|
|
583
|
+
// record, a thread, a transcript, a work log, a scorecard entry with real evidence. Before
|
|
584
|
+
// this it was a bare model call whose task.started/task.done named a task the record did
|
|
585
|
+
// not have, so the fold dropped them and the writer of the final answer showed nowhere.
|
|
586
|
+
// A resumed run that died mid-merge finds the task on its plan and continues it.
|
|
587
|
+
let mergeTask = tasks.find((x) => x.kind === 'merge');
|
|
588
|
+
if (!mergeTask) {
|
|
589
|
+
mergeTask = {
|
|
590
|
+
id: 'merge', kind: 'merge', role: judge.id, title: `merge (${judge.name || judge.id})`,
|
|
591
|
+
prompt: [
|
|
592
|
+
`You are the ${judge.name || judge.id} of team "${t.name}". The members' work is on the board below (and in the board tool). Write the team's FINAL ANSWER to the request: complete, well organised, only what the findings support, with the refs they came from. Say plainly what was not found or assumed. Flag anything the members disagreed on. Do not research from scratch — verify a figure with a tool only where the board is silent or contradictory.`,
|
|
593
|
+
`Request: ${String(request || '').trim()}`,
|
|
594
|
+
].join('\n\n'),
|
|
595
|
+
dependsOn: tasks.filter((x) => x.role && x.kind !== 'merge').map((x) => x.id),
|
|
596
|
+
};
|
|
597
|
+
tasks.push(mergeTask);
|
|
598
|
+
say('task.added', { taskId: mergeTask.id, kind: 'merge', role: mergeTask.role, title: mergeTask.title, dependsOn: mergeTask.dependsOn });
|
|
599
|
+
board.openThread({ taskId: mergeTask.id, kind: 'task', title: mergeTask.title, by: RUNNER, holder: judge.id });
|
|
616
600
|
}
|
|
601
|
+
const done = tasksOut.find((x) => x.id === mergeTask.id && x.status === 'ok');
|
|
602
|
+
const row = done || (modelFor(judge)?.model && budget.canAfford({ tokens: 0 }) ? await runTask(mergeTask) : null);
|
|
603
|
+
// The merge is a task: stopped or waiting on a person mid-way, the run is too — and it
|
|
604
|
+
// resumes from the merge's transcript, like any other.
|
|
605
|
+
if (stopped()) return finish('stopped');
|
|
606
|
+
if (waitingOnPerson) return finish('waiting', { proposal: null, budgetAsked });
|
|
607
|
+
proposal = row?.status === 'ok' && String(row.text || '').trim() ? { kind: 'answer', text: String(row.text), by: judge.id, taskId: mergeTask.id } : mergeCheap(t, board.all(), tasksOut);
|
|
617
608
|
} else if (t.merge === 'converge') {
|
|
618
609
|
const drafts = okTasks.map((x) => ({ claims: toBriefClaims(x.findings) }));
|
|
619
610
|
const { agreed, disputed } = converge(drafts, { minAgree: Math.min(2, drafts.length) });
|
package/team-tool.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// The `team` tool — a team invoked from any chat: run, dry_run, save
|
|
1
|
+
// The `team` tool — a team invoked from any chat: run, dry_run, save — and `project`, a goal
|
|
2
|
+
// handed to the executive (project-run.js): jobs, recruiting, rounds run as teams, a review,
|
|
3
|
+
// follow-ups, done-when, every step on the project record a person reads on the board.
|
|
2
4
|
//
|
|
3
5
|
// The recipe tool's twin, on purpose: one registered tool whose description is the
|
|
4
6
|
// catalogue, `dry_run` showing what a person would approve, `save` proposing a team from the
|
|
@@ -48,13 +50,18 @@ export function teamToolSpec(teams) {
|
|
|
48
50
|
+ 'Actions: {"action":"run","name":"<team>","request":"<what to do>"} runs one (streams; may take a while); '
|
|
49
51
|
+ '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
|
|
50
52
|
+ '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
|
|
53
|
+
+ '{"action":"project","goal":"<what done looks like>","title":"<short name>","doneWhen":"<a check the result can be held to>","budget":{"tokens":200000,"ms":3600000}} hands a GOAL to the executive: it posts jobs, recruits agents from the pool for each, runs them as teams in rounds, reviews the results against done-when, posts follow-ups, asks the user before spending or changing scope, and closes when done-when holds (may take a long while; use it for a goal with several parts, not a question). '
|
|
51
54
|
+ 'A team: {"name":"research" (a short identifier: letters, digits, - _; used as /research),"description":"…","roles":[{"id":"researcher","prompt":"…","prefer":"balanced","grants":["data","web"]},{"id":"writer","prompt":"…","prefer":"strong","grants":["none"]}],"merge":"judge","judge":"writer","budget":{"tokens":40000,"ms":300000}}. '
|
|
52
55
|
+ 'grants: none | data | web | history | mcp | mcp:<server> | shell | fs:write | scm:read | scm:push | scm:pr. A role may say "agent":"<id>" instead of a prompt to stand for an agent from the pool. merge: judge | converge | concat | first. A budget is required. '
|
|
53
56
|
+ 'Order the work with "dependsOn": a role that builds on another\'s findings (a budget checker on a researcher) lists it, so it runs after and reads the board instead of searching again. The judge does not need a task of its own - the merge is its work.',
|
|
54
57
|
parameters: {
|
|
55
58
|
type: 'object',
|
|
56
59
|
properties: {
|
|
57
|
-
action: { type: 'string', enum: ['run', 'dry_run', 'save'] },
|
|
60
|
+
action: { type: 'string', enum: ['run', 'dry_run', 'save', 'project'] },
|
|
61
|
+
goal: { type: 'string', description: 'For project: the goal, in the user\'s words — what done looks like.' },
|
|
62
|
+
title: { type: 'string', description: 'For project: a short name.' },
|
|
63
|
+
doneWhen: { type: 'string', description: 'For project: the check the result is held to.' },
|
|
64
|
+
budget: { type: 'object', description: 'For project: {"tokens","ms","usd"} for the whole project.', additionalProperties: true },
|
|
58
65
|
name: { type: 'string', description: 'Team name, for run / dry_run.' },
|
|
59
66
|
request: { type: 'string', description: 'What the team should do, for run / dry_run.' },
|
|
60
67
|
team: { type: 'object', description: 'The team to save, for save.', additionalProperties: true },
|
|
@@ -82,13 +89,16 @@ const json = (v) => JSON.stringify(v);
|
|
|
82
89
|
* @param appoint `(role) => { model, mode } | null` for the dry run
|
|
83
90
|
* @param confirmSave `async (detail, team) => 'allow' | 'deny'`; absent = save refused
|
|
84
91
|
* @param saveTeam `async (team) => void`
|
|
92
|
+
* @param runProject `async ({ goal, title, doneWhen, budget, toolset }) => project result` — the
|
|
93
|
+
* host's executive loop (project-run.js runProject with its own deps);
|
|
94
|
+
* absent, the action is refused
|
|
85
95
|
*/
|
|
86
96
|
/**
|
|
87
97
|
* `resolve` is the host's `(team) => team` that fills roles standing for agents from the
|
|
88
98
|
* pool (agent.js resolveTeam) — applied before a dry run and before a run, never to what is
|
|
89
99
|
* saved: the stored team keeps its references, the run gets the cards as they are now.
|
|
90
100
|
*/
|
|
91
|
-
export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null } = {}) {
|
|
101
|
+
export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null, runProject = null } = {}) {
|
|
92
102
|
const byName = new Map(usable(teams).map((t) => [t.name, t]));
|
|
93
103
|
// A team whose roles stand for agents is filled from the pool on the way to a run; a
|
|
94
104
|
// resolver that throws (an agent missing from the pool) is the tool's error, not a crash.
|
|
@@ -103,6 +113,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
103
113
|
specs: [teamToolSpec(teams)],
|
|
104
114
|
system: [
|
|
105
115
|
byName.size ? 'Saved agent teams exist (see the `team` tool). When a request is broad enough that several roles would do it better — research plus writing, several sources to reconcile — run the matching team rather than doing it all in one turn.' : '',
|
|
116
|
+
runProject ? 'A GOAL with several parts (a project, not a question) goes to the executive: call the `team` tool with {"action":"project","goal":…,"doneWhen":…} and report what it produced.' : '',
|
|
106
117
|
(confirmSave && saveTeam) ? 'When the user asks to create, make, set up or save a team (of agents / roles), do not run one: call the `team` tool with {"action":"save","team":{…}} — pick roles, prompts, grants and a budget from what they said, and ask only for what you cannot infer. The user approves it on a card.' : '',
|
|
107
118
|
].filter(Boolean).join(' '),
|
|
108
119
|
bind(toolset) { bound = toolset; },
|
|
@@ -127,6 +138,26 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
127
138
|
return json({ saved: stored.name, roles: stored.roles.map((r) => r.id), hint: `Run it with {"action":"run","name":"${stored.name}","request":"…"} or by typing /${stored.name}.` });
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
if (action === 'project') {
|
|
142
|
+
const goal = String(input?.goal || '').trim();
|
|
143
|
+
if (!goal) return json({ error: 'project needs a goal — what does done look like?' });
|
|
144
|
+
if (typeof runProject !== 'function') return json({ error: 'This surface cannot run a project. Run a team instead, or describe the jobs.' });
|
|
145
|
+
// One project per turn, like one run per team: a loop that asks the person and gets
|
|
146
|
+
// "stop" is not started again with a rephrased goal.
|
|
147
|
+
if (ran.has('project')) { const p = ran.get('project'); return json({ error: `A project already ran in this turn (${p.projectId}, ${p.status}). Report its result and ask the user how to proceed.`, ...p }); }
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await runProject({ goal, title: String(input?.title || '').trim(), doneWhen: String(input?.doneWhen || '').trim(), budget: input?.budget && typeof input.budget === 'object' ? input.budget : null, toolset: bound });
|
|
151
|
+
} catch (e) { return json({ error: e?.message || String(e) }); }
|
|
152
|
+
const out = { projectId: result.projectId, status: result.status, rounds: result.rounds, jobs: result.jobs, runs: result.runs, report: result.report, spend: result.spend, why: result.why || undefined };
|
|
153
|
+
ran.set('project', out);
|
|
154
|
+
const hint = result.status === 'done' ? 'Done-when holds and the project is closed. Present the report as the answer; the jobs and their runs are on the board.'
|
|
155
|
+
: result.status === 'over-budget' ? 'The project stopped at its budget; the report so far stands. Say so and ask whether to raise it.'
|
|
156
|
+
: result.status === 'failed' ? `The project FAILED — ${result.why || 'see the jobs'}. Do not start it again this turn; tell the user which job failed and why.`
|
|
157
|
+
: `The project is ${result.status}${result.why ? ` — ${result.why}` : ''}. Present the report so far and say what is left; do not start it again this turn.`;
|
|
158
|
+
return json({ ...out, hint });
|
|
159
|
+
}
|
|
160
|
+
|
|
130
161
|
const team = byName.get(String(input?.name || ''));
|
|
131
162
|
if (!team) return json({ error: `No team named "${input?.name}".`, available: [...byName.keys()] });
|
|
132
163
|
const request = String(input?.request || '').trim();
|
|
@@ -159,7 +190,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
159
190
|
: 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;
|
|
160
191
|
return json({ name: team.name, runId: result.runId, status: result.status, proposal: result.proposal, tasks, findings, usage: result.usage, hint });
|
|
161
192
|
}
|
|
162
|
-
return json({ error: `Unknown action "${action}". Use run, dry_run or
|
|
193
|
+
return json({ error: `Unknown action "${action}". Use run, dry_run, save or project.` });
|
|
163
194
|
},
|
|
164
195
|
};
|
|
165
196
|
}
|
package/team-trail.js
CHANGED
|
@@ -9,6 +9,7 @@ export function teamLine(ev) {
|
|
|
9
9
|
switch (ev.type) {
|
|
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
|
+
case 'task.added': return { type: 'status', text: `${ev.kind === 'merge' ? 'the merge' : ev.title || ev.taskId} is ${role}'s${(ev.dependsOn || []).length ? ` — after ${ev.dependsOn.join(', ')}` : ''}` };
|
|
12
13
|
case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}${ev.resumed ? ` (resumed, ${ev.steps} steps so far)` : ''}` };
|
|
13
14
|
case 'task.waiting': return { type: 'status', text: `${role} is waiting on you — ${ev.text || 'a question on the board'}` };
|
|
14
15
|
case 'run.waiting': return { type: 'status', text: `waiting on you — ${ev.text || ev.type || 'a question on the board'}` };
|
|
@@ -30,6 +31,14 @@ export function teamLine(ev) {
|
|
|
30
31
|
case 'task.failed': return { type: 'error', text: `${role} ${ev.status || 'failed'}${ev.error ? ` — ${ev.error}` : ''}` };
|
|
31
32
|
case 'run.merging': return { type: 'status', text: `merging (${ev.policy})` };
|
|
32
33
|
case 'run.done': return { type: 'status', text: `team ${ev.status}${ev.usage?.spent?.tokens ? ` · ${ev.usage.spent.tokens} tokens` : ''}` };
|
|
34
|
+
// A PROJECT (project-run.js): the executive's steps, the jobs, the report — the same
|
|
35
|
+
// strip a run's lines go to, so a person sees the loop move without opening the board.
|
|
36
|
+
case 'project.thinking': return { type: 'status', text: `executive is ${ev.what === 'project_review' ? 'reviewing the round' : 'planning the jobs'}${ev.model ? ` (${ev.model})` : ''}` };
|
|
37
|
+
case 'project.status': return { type: 'status', text: `project ${ev.status}${ev.by ? ` (${ev.by})` : ''}` };
|
|
38
|
+
case 'project.decision': return ev.kind === 'answer' || ev.kind === 'status' ? null : { type: 'status', text: `${ev.by || 'executive'} · ${ev.kind}: ${String(ev.text || '').split('\n')[0].slice(0, 140)}` };
|
|
39
|
+
case 'job.posted': return { type: 'status', text: `job posted: ${ev.job?.title || ev.job?.id || '?'}${ev.job?.needs?.skills?.length ? ` (${ev.job.needs.skills.join(', ')})` : ''}` };
|
|
40
|
+
case 'job.updated': return ev.job?.status && ['recruited', 'done', 'failed'].includes(ev.job.status) ? { type: ev.job.status === 'failed' ? 'error' : 'status', text: `job ${ev.job.id} ${ev.job.status}${ev.job.recruited?.agentId ? ` → ${ev.job.recruited.agentId}` : ''}${ev.job.status === 'failed' && ev.job.result?.text ? ` — ${String(ev.job.result.text).slice(0, 120)}` : ''}` } : null;
|
|
41
|
+
case 'project.report': return { type: 'status', text: `report written (${ev.by || 'executive'})` };
|
|
33
42
|
default: return null;
|
|
34
43
|
}
|
|
35
44
|
}
|
|
@@ -40,6 +49,7 @@ export function teamLanes(prev, ev) {
|
|
|
40
49
|
switch (ev.type) {
|
|
41
50
|
case 'run.started': lanes.team = ev.team; lanes.roles = ev.roles; break;
|
|
42
51
|
case 'plan.ready': for (const t of ev.tasks || []) lanes.tasks[t.id] = { id: t.id, role: t.role, title: t.title, status: 'pending', findings: 0 }; break;
|
|
52
|
+
case 'task.added': lanes.tasks[ev.taskId] = { id: ev.taskId, role: ev.role, title: ev.title, status: 'pending', findings: 0, ...(ev.kind ? { kind: ev.kind } : {}) }; break;
|
|
43
53
|
case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
|
|
44
54
|
case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
|
|
45
55
|
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;
|