ai-agentmanager 1.0.2 → 1.1.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/CHANGELOG.md +13 -0
- package/README.md +58 -3
- package/docs/architecture.md +1 -0
- package/package.json +1 -1
- package/public/app.js +398 -7
- package/public/index.html +90 -0
- package/public/style.css +42 -1
- package/src/cleanup.js +185 -0
- package/src/cli.js +164 -0
- package/src/config.js +4 -0
- package/src/engines/antigravity.js +1 -1
- package/src/engines/claude.js +8 -5
- package/src/engines/codex.js +1 -1
- package/src/manager.js +39 -8
- package/src/server.js +44 -2
- package/src/teams.js +984 -0
- package/src/tracker.js +6 -0
package/src/teams.js
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
// Agent teams: one problem, several agents in roles. The analyst writes an analysis; critics
|
|
2
|
+
// agree, ask for changes or raise questions until they align; the implementer builds it; the
|
|
3
|
+
// tester tests it; the reviewer approves it or sends it back to the implementer. Agents hand off
|
|
4
|
+
// through Markdown files in the team's folder, each ending with a "verdict:" line. The daemon
|
|
5
|
+
// reads the verdicts, tells the next agent what to read, and stops for the person when the
|
|
6
|
+
// reviewer approves, or when the agents can't agree within the round limits.
|
|
7
|
+
// All members share one worktree and branch (the implementer's); only the implementer and the
|
|
8
|
+
// tester change it, one at a time. Teams live in the state file next to the agents.
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { expandHome, paths } from './config.js';
|
|
15
|
+
import { engineOf } from './engines/index.js';
|
|
16
|
+
import * as git from './git.js';
|
|
17
|
+
|
|
18
|
+
const err = (status, message) => Object.assign(new Error(message), { status });
|
|
19
|
+
const now = () => new Date().toISOString();
|
|
20
|
+
|
|
21
|
+
export const ROLES = ['analyst', 'critic', 'implementer', 'tester', 'reviewer'];
|
|
22
|
+
const ROLE_LABEL = { analyst: 'Analyst', critic: 'Critic', implementer: 'Implementer', tester: 'Tester', reviewer: 'Reviewer' };
|
|
23
|
+
|
|
24
|
+
/** The words each kind of handoff file may end with. */
|
|
25
|
+
export const VERDICTS = {
|
|
26
|
+
analysis: ['ready'],
|
|
27
|
+
critique: ['agree', 'changes', 'questions'],
|
|
28
|
+
implementation: ['done', 'blocked'],
|
|
29
|
+
test: ['pass', 'fail'],
|
|
30
|
+
review: ['approve', 'changes'],
|
|
31
|
+
final: ['done'],
|
|
32
|
+
blocked: ['blocked'],
|
|
33
|
+
};
|
|
34
|
+
/** Handoff file names a lead's sub-agents write: kind, role and which round. */
|
|
35
|
+
const FILES = [
|
|
36
|
+
[/^analysis-(\d+)\.md$/, 'analysis', 'analyst'],
|
|
37
|
+
[/^critique-(\d+)-(\d+)\.md$/, 'critique', 'critic'],
|
|
38
|
+
[/^implementation-(\d+)\.md$/, 'implementation', 'implementer'],
|
|
39
|
+
[/^test-(\d+)\.md$/, 'test', 'tester'],
|
|
40
|
+
[/^review-(\d+)\.md$/, 'review', 'reviewer'],
|
|
41
|
+
[/^blocked-(\d+)\.md$/, 'blocked', 'lead'],
|
|
42
|
+
[/^final\.md$/, 'final', 'lead'],
|
|
43
|
+
];
|
|
44
|
+
const SYNONYMS = {
|
|
45
|
+
agreed: 'agree', approved: 'approve', lgtm: 'approve', passed: 'pass', passes: 'pass', failed: 'fail', fails: 'fail',
|
|
46
|
+
complete: 'done', completed: 'done', finished: 'done', question: 'questions', change: 'changes', request: 'changes',
|
|
47
|
+
rejected: 'changes', reject: 'changes', disagree: 'changes', disagreed: 'changes',
|
|
48
|
+
};
|
|
49
|
+
// "gaps": a lead's reviewer approved work that leaves parts of the request unbuilt; the lead is
|
|
50
|
+
// starting the round that builds them.
|
|
51
|
+
const ACTIVE = new Set(['analyze', 'discuss', 'implement', 'test', 'review', 'gaps', 'finalizing']);
|
|
52
|
+
|
|
53
|
+
export const teamDir = (id) => path.join(paths.teams, id);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A new project's folder, made a git repository with an empty first commit on main, so the team
|
|
57
|
+
* gets a worktree and a branch like any other. An existing folder must be empty or already a repo.
|
|
58
|
+
*/
|
|
59
|
+
export function startRepo(dir) {
|
|
60
|
+
const g = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' }).toString().trim();
|
|
61
|
+
if (fs.existsSync(dir)) {
|
|
62
|
+
try {
|
|
63
|
+
if (g('rev-parse', '--is-inside-work-tree') === 'true') return { dir, created: false };
|
|
64
|
+
} catch {}
|
|
65
|
+
if (fs.readdirSync(dir).length) throw err(400, `${dir} has files but isn't a git repository: run "git init" there, or pick an empty or new folder`);
|
|
66
|
+
}
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
g('init', '-q', '-b', 'main');
|
|
69
|
+
try {
|
|
70
|
+
g('commit', '-q', '--allow-empty', '-m', 'Start the project');
|
|
71
|
+
} catch {
|
|
72
|
+
// No git identity configured on this machine: sign the empty first commit as AI Agent Manager.
|
|
73
|
+
g('-c', 'user.name=AI Agent Manager', '-c', 'user.email=agentmgr@localhost', 'commit', '-q', '--allow-empty', '-m', 'Start the project');
|
|
74
|
+
}
|
|
75
|
+
return { dir, created: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The verdict a handoff file ends with ("verdict: approve"), normalized; null if none or not allowed. */
|
|
79
|
+
export function readVerdict(text, allowed) {
|
|
80
|
+
const all = [...String(text ?? '').matchAll(/^[\s>*_#-]*verdict[\s*_`]*:[\s*_`]*([a-z][a-z-]*)/gim)];
|
|
81
|
+
const word = all.at(-1)?.[1].toLowerCase();
|
|
82
|
+
if (!word) return null;
|
|
83
|
+
const v = SYNONYMS[word] ?? word;
|
|
84
|
+
return allowed.includes(v) ? v : (allowed.find((a) => v.startsWith(a)) ?? null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a review found the whole request built: its last "coverage:" line ("complete" or "partial"); null if none. */
|
|
88
|
+
export function readCoverage(text) {
|
|
89
|
+
const all = [...String(text ?? '').matchAll(/^[\s>*_#-]*coverage[\s*_`]*:[\s*_`]*([a-z]+)/gim)];
|
|
90
|
+
const word = all.at(-1)?.[1].toLowerCase();
|
|
91
|
+
if (!word) return null;
|
|
92
|
+
if (['complete', 'full', 'covered'].includes(word)) return 'complete';
|
|
93
|
+
if (['partial', 'incomplete', 'gaps', 'missing'].includes(word)) return 'partial';
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The "Gaps" section of a review: what the request asks for that isn't built yet. */
|
|
98
|
+
export function gapsOf(text) {
|
|
99
|
+
const m = /^(#{1,6})\s*gaps\b.*$/im.exec(String(text ?? ''));
|
|
100
|
+
if (!m) return '';
|
|
101
|
+
const rest = text.slice(m.index + m[0].length);
|
|
102
|
+
const end = rest.search(new RegExp(`^#{1,${m[1].length}}\\s`, 'm'));
|
|
103
|
+
return (end < 0 ? rest : rest.slice(0, end)).replace(/^[\s>*_-]*(verdict|coverage)\b.*$/gim, '').trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A line or two from a handoff file for the timeline: its first prose, without headings or the verdict. */
|
|
107
|
+
export function summarize(text, max = 240) {
|
|
108
|
+
const lines = String(text ?? '')
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((l) => l.trim())
|
|
111
|
+
.filter((l) => l && !/^#/.test(l) && !/^[>*_\s-]*verdict\b/i.test(l) && !/^(-{3,}|```)/.test(l));
|
|
112
|
+
const s = lines.join(' ').replace(/\s+/g, ' ');
|
|
113
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const DUTIES = {
|
|
117
|
+
analyst:
|
|
118
|
+
'Your job: study the problem and the code, then write an analysis the team can build from. Do not change any project files. ' +
|
|
119
|
+
'The analysis covers: the problem in your own words; what you found (root cause or current behavior, with file and line references); ' +
|
|
120
|
+
'the proposed approach, step by step; the files to change; risks and edge cases; how to test it; open questions. ' +
|
|
121
|
+
'When critics reply, address every point: change the analysis, or explain in it why you disagree.',
|
|
122
|
+
critic:
|
|
123
|
+
'Your job: challenge the analysis before anyone writes code. Is the problem understood? Is the approach right, and the smallest one that works? ' +
|
|
124
|
+
'What is missing, risky or wrong? Check its claims against the code yourself. Do not change any project files. ' +
|
|
125
|
+
'Number your points and mark each [must], [should] or [question]. Use "agree" only when no [must] point or open question is left.',
|
|
126
|
+
implementer:
|
|
127
|
+
'Your job: implement the analysis the team agreed on, in this working copy, and commit on the current branch with clear messages. ' +
|
|
128
|
+
"Don't push or open a pull request until you are told to. Your implementation notes list what changed (files), " +
|
|
129
|
+
'anything you did differently from the analysis and why, and how to test it. When the tester or the reviewer sends the work back, ' +
|
|
130
|
+
'fix every [must] item and the [should] items that make sense.',
|
|
131
|
+
tester:
|
|
132
|
+
'Your job: test the implementation. Run the project\'s tests, linters and build; exercise the change the way a user would; try the edge cases ' +
|
|
133
|
+
'from the analysis. You may add or improve tests (commit them separately), but do not fix product code: report failures for the implementer. ' +
|
|
134
|
+
'Your test report lists the commands you ran with their results, what passed, and each failure with steps to reproduce. ' +
|
|
135
|
+
'Use "fail" for anything that must be fixed before review.',
|
|
136
|
+
reviewer:
|
|
137
|
+
'Your job: review the change as a senior engineer would before it merges: correctness, edge cases, security, tests, readability, ' +
|
|
138
|
+
"the project's conventions, and whether it does what the analysis agreed. Do not change any project files. " +
|
|
139
|
+
'Your review has a short summary, findings marked [must], [should] or [nit], and what is good. Use "approve" only when no [must] finding is left. ' +
|
|
140
|
+
'Then check coverage: compare what is built with everything the request asks for (in a gap round, the request it fills gaps for). ' +
|
|
141
|
+
'In a section "## Gaps", list each part that is asked for but not built yet, or write "None". Just before the verdict line, add a line "coverage: complete" or "coverage: partial". ' +
|
|
142
|
+
'Missing parts alone are no reason for "changes": approve good work that is only incomplete; the team builds the gaps in another round.',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Instructions every launch of a team member gets (an appended system prompt). */
|
|
146
|
+
export function teamPrompt(role, dir) {
|
|
147
|
+
return [
|
|
148
|
+
`You are the ${ROLE_LABEL[role]?.toLowerCase() ?? role} on a team of AI agents solving one problem together: an analyst, critics, an implementer, a tester and a reviewer.`,
|
|
149
|
+
DUTIES[role] ?? '',
|
|
150
|
+
`How the team works: you hand off through Markdown files in the team folder ${dir}. Each message tells you which files to read and which file to write. ` +
|
|
151
|
+
'Write that file only when you are finished, and end it with a line "verdict: <word>" using one of the words you are given; ' +
|
|
152
|
+
'AI Agent Manager reads that line and passes the work on. Then stop and wait: a message comes when it is your turn again. ' +
|
|
153
|
+
"Don't edit other members' files, and don't watch the folder or wait for the others yourself. " +
|
|
154
|
+
'Questions for the other members go in your file; questions only the person can answer go in your questions file, as usual.',
|
|
155
|
+
].join('\n\n');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const SUB_ROLES = ['analyst', 'critic', 'implementer', 'tester', 'reviewer'];
|
|
159
|
+
const SUB_DESCRIPTION = {
|
|
160
|
+
analyst: "The team's analyst: studies the problem and the code and writes the analysis file. Use it for the team's analysis step.",
|
|
161
|
+
critic: "The team's critic: challenges the analysis and writes a critique file. Use it for the team's discussion step.",
|
|
162
|
+
implementer: "The team's implementer: builds the agreed analysis, commits, and writes implementation notes. Use it for the team's implementation step.",
|
|
163
|
+
tester: "The team's tester: tests the implementation and writes a test report. Use it for the team's test step.",
|
|
164
|
+
reviewer: "The team's reviewer: reviews the change and writes a review. Use it for the team's review step.",
|
|
165
|
+
};
|
|
166
|
+
const KIND_OF = { analyst: 'analysis', critic: 'critique', implementer: 'implementation', tester: 'test', reviewer: 'review' };
|
|
167
|
+
|
|
168
|
+
/** The lead's sub-agents, for Claude Code's --agents: one per role, each writing its own handoff files. */
|
|
169
|
+
export function subagents(team, dir) {
|
|
170
|
+
return Object.fromEntries(
|
|
171
|
+
SUB_ROLES.filter((r) => r !== 'critic' || team.settings.critics).map((role) => [
|
|
172
|
+
role,
|
|
173
|
+
{
|
|
174
|
+
description: SUB_DESCRIPTION[role],
|
|
175
|
+
prompt: [
|
|
176
|
+
DUTIES[role],
|
|
177
|
+
`You work for the lead of a team of AI agents. The lead's message tells you which files in the team folder ${dir} to read. ` +
|
|
178
|
+
`Don't write report files: return your whole ${{ analyst: 'analysis', critic: 'critique', implementer: 'implementation notes', tester: 'test report', reviewer: 'review' }[role]} as your reply, in Markdown, ` +
|
|
179
|
+
`ending with a line "verdict: <word>" using one of: ${VERDICTS[KIND_OF[role]].join(', ')}. The lead saves it to the team folder as it is. ` +
|
|
180
|
+
"Don't ask the person questions: make the call yourself, say what you assumed and why, and carry on.",
|
|
181
|
+
].join('\n\n'),
|
|
182
|
+
...(team.settings.models?.[role] ? { model: team.settings.models[role] } : {}),
|
|
183
|
+
},
|
|
184
|
+
]),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Instructions for a team's lead: run the pipeline through sub-agents for each request (a quest),
|
|
190
|
+
* stop for the person at the end of each, and stay for the next request.
|
|
191
|
+
*/
|
|
192
|
+
export function leadPrompt(team, dir, baseRef) {
|
|
193
|
+
const s = team.settings;
|
|
194
|
+
const f = (name) => path.join(dir, 'quest-<n>', name);
|
|
195
|
+
const steps = [
|
|
196
|
+
`1. Analysis: ask the analyst sub-agent to write ${f('analysis-1.md')}.`,
|
|
197
|
+
s.critics
|
|
198
|
+
? `2. Discussion: ask ${s.critics === 2 ? 'two critic sub-agents (in parallel), critic 1 and critic 2,' : 'the critic sub-agent'} to review the latest analysis and write ${f('critique-<round>-<critic number>.md')}. ` +
|
|
199
|
+
`If every critic's verdict is "agree", go on. Otherwise ask the analyst to address every point in ${f('analysis-<round + 1>.md')} (changing the analysis or saying why not), then ask the critics again. ` +
|
|
200
|
+
`At most ${s.maxRounds} rounds. If only small points are left then, have the analyst apply the critics' proposed fixes in the final analysis and go on; write ${f('blocked-1.md')} (the next free number), ending with "verdict: blocked", and stop only if they disagree on the approach itself.`
|
|
201
|
+
: '2. There are no critics on this team: go on with the analysis as it is.',
|
|
202
|
+
`3. Copy the agreed (latest) analysis to ${f('analysis.md')}.`,
|
|
203
|
+
`4. Implementation: ask the implementer sub-agent to implement ${f('analysis.md')} in this working copy, commit on the current branch (no push, no pull request), and write ${f('implementation-<round>.md')}, starting at round 1. If its verdict is "blocked", write a blocked file as above and stop.`,
|
|
204
|
+
`5. Test: ask the tester sub-agent to test it and write ${f('test-<round>.md')}${s.testCommand ? ` (the project's test command is: ${s.testCommand})` : ''}. On "fail", start the next implementation round with the test report.`,
|
|
205
|
+
`6. Review: ask the reviewer sub-agent to review the change${baseRef ? ` (git diff ${baseRef.split(' @ ')[1] ?? baseRef}...HEAD)` : ''} against the analysis and the test report, and write ${f('review-<round>.md')}. On "changes", start the next implementation round with the review.`,
|
|
206
|
+
`At most ${s.maxLoops} implementation rounds per quest: if the work is still sent back after that for anything beyond small fixes, write a blocked file saying what is left, and stop.`,
|
|
207
|
+
`7. A gap round builds what the reviewer says is still missing ("coverage: partial", the review's Gaps section). It is the next quest: write ${path.join(dir, 'quest-<next n>', 'request.md')} starting with the heading "# Gap round <k>", then the request it fills gaps for (the problem, or the person's request) and the gaps from the review, and run the steps for it. The reviewer checks coverage against that request. ` +
|
|
208
|
+
(s.gapRounds
|
|
209
|
+
? `When the reviewer approves with gaps and fewer than ${s.gapRounds} gap round${s.gapRounds > 1 ? 's' : ''} have run for this request, start the next one yourself, without asking.`
|
|
210
|
+
: 'Ask the person before each gap round: when the reviewer approves with gaps, stop (step 8) and list the gaps; start a gap round only when the person says so.'),
|
|
211
|
+
'8. When the reviewer approves (and no gap round starts by itself): stop. Tell the person in a few lines what the team built, what the reviewer said, and the gaps still left, if any, asking whether to run a gap round for them; then wait for their decision. Do not push, merge or open a pull request unless the person tells you to.',
|
|
212
|
+
];
|
|
213
|
+
const quests =
|
|
214
|
+
`Each request the team works on is a quest with its own folder: the first problem is quest 1, in ${path.join(dir, 'quest-1')}. ` +
|
|
215
|
+
"You stay after a quest ends. When the person asks for new work in this conversation (a feature, a fix, a change), it is the next quest: " +
|
|
216
|
+
`first write the request in its own words to ${path.join(dir, 'quest-<next n>', 'request.md')}, then run the same steps for it in that folder, starting again at analysis-1.md and implementation round 1, on the same branch. ` +
|
|
217
|
+
"A question about the team's work isn't a quest: just answer it.";
|
|
218
|
+
return [
|
|
219
|
+
`You lead a team of AI agents solving one problem: ${SUB_ROLES.filter((r) => r !== 'critic' || s.critics).join(', ')}. They are your sub-agents (the Agent tool).`,
|
|
220
|
+
"You coordinate; you don't do their work. Delegate each step to the sub-agent for that role and give it the problem and the paths of the files it should read. " +
|
|
221
|
+
'Sub-agents return their work as their reply, ending with a verdict line: save each reply, unedited, to the file named for that step, then read its verdict to decide the next step. ' +
|
|
222
|
+
'Every handoff goes through these files in the team folder; AI Agent Manager reads them to show the person how the team is doing. ' +
|
|
223
|
+
"Don't write an analysis, critique, implementation note, test report or review of your own: save what the sub-agent returned. If a reply has no verdict line, ask that sub-agent for it.",
|
|
224
|
+
'Run the whole quest without asking the person anything. You make the calls: choose between options yourself (the simplest one that meets the request), and record each decision and why in the file of that step. ' +
|
|
225
|
+
"Don't ask for permission or confirmation between steps; the person can stop you at any time. When the critics' remaining points come with concrete fixes, have the analyst fold them in and go on. " +
|
|
226
|
+
'Stop only when the reviewer approves (the end of the quest), or when the team needs something only the person can give (credentials, access, or a business decision with no sensible default): then write a blocked file saying exactly what is needed.',
|
|
227
|
+
quests,
|
|
228
|
+
`The steps of a quest (<n> is its number):\n${steps.join('\n')}`,
|
|
229
|
+
'If a question really is for the person, put it in your questions file, as usual, and keep the team going on a sensible default meanwhile.',
|
|
230
|
+
].join('\n\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class Teams extends EventEmitter {
|
|
234
|
+
constructor({ manager, store, config = {} }) {
|
|
235
|
+
super();
|
|
236
|
+
this.manager = manager;
|
|
237
|
+
this.store = store;
|
|
238
|
+
this.config = config;
|
|
239
|
+
store.data.teams ??= {};
|
|
240
|
+
manager.on('change', (a) => a.team && store.data.teams[a.team.id] && this.emit('change', a.team.id));
|
|
241
|
+
manager.on('removed', (id) => {
|
|
242
|
+
for (const team of Object.values(store.data.teams)) {
|
|
243
|
+
const m = this.memberList(team).find((x) => x.id === id);
|
|
244
|
+
if (!m) continue;
|
|
245
|
+
if (team.mode === 'lead' ? ACTIVE.has(team.stage) : team.waiting.some((w) => w.member === id)) this.block(team, `The ${m.role} (${m.name}) was deleted`, 'member');
|
|
246
|
+
else this.save(team);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
// The judge leaves members alone between turns: idle is how they wait.
|
|
250
|
+
manager.idleExpected = (id) => {
|
|
251
|
+
const a = store.get(id);
|
|
252
|
+
const team = a?.team && store.data.teams[a.team.id];
|
|
253
|
+
if (!team) return false;
|
|
254
|
+
// A lead is meant to work until the team stops for the person.
|
|
255
|
+
if (team.mode === 'lead') return !ACTIVE.has(team.stage);
|
|
256
|
+
return !team.waiting.some((w) => w.member === id && !w.verdict);
|
|
257
|
+
};
|
|
258
|
+
// Messages still queued when the daemon stopped are gone; say them again (a first message
|
|
259
|
+
// is kept on the agent and starts it on resume).
|
|
260
|
+
this.resendTimer = setTimeout(() => {
|
|
261
|
+
for (const team of Object.values(store.data.teams)) {
|
|
262
|
+
if (!ACTIVE.has(team.stage)) continue;
|
|
263
|
+
const lead = team.mode === 'lead' && this.manager.store.get(team.members.lead);
|
|
264
|
+
if (lead?.lastStartedAt) this.manager.tell(lead.id, `AI Agent Manager restarted while the team was working, which may have cut a sub-agent off. Check the team folder ${team.dir} and continue from where the team stopped.`, { source: 'team' });
|
|
265
|
+
for (const w of team.waiting) {
|
|
266
|
+
const a = this.manager.store.get(w.member);
|
|
267
|
+
if (w.verdict || !w.text || !a?.lastStartedAt || fs.existsSync(this.path(team, w.file))) continue;
|
|
268
|
+
this.manager.tell(w.member, `(Repeated after a restart, in case it didn't reach you.) ${w.text}`, { source: 'team' });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}, 5000);
|
|
272
|
+
this.resendTimer.unref();
|
|
273
|
+
this.timer = setInterval(() => this.poll(), 2000);
|
|
274
|
+
this.timer.unref();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
save(team) {
|
|
278
|
+
if (team) team.updatedAt = now();
|
|
279
|
+
this.store.save();
|
|
280
|
+
if (team) this.emit('change', team.id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get(id) {
|
|
284
|
+
const team = this.store.data.teams[id];
|
|
285
|
+
if (!team) throw err(404, `No team ${id}`);
|
|
286
|
+
return team;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
list() {
|
|
290
|
+
return Object.values(this.store.data.teams).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map((t) => this.view(t.id));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Members in pipeline order: { role, n, id, name }. */
|
|
294
|
+
memberList(team) {
|
|
295
|
+
const m = team.members;
|
|
296
|
+
if (team.mode === 'lead') return [{ role: 'lead', id: m.lead, name: this.manager.store.get(m.lead)?.name ?? null }];
|
|
297
|
+
return [
|
|
298
|
+
{ role: 'analyst', id: m.analyst },
|
|
299
|
+
...m.critics.map((id, i) => ({ role: 'critic', n: i + 1, id })),
|
|
300
|
+
{ role: 'implementer', id: m.implementer },
|
|
301
|
+
{ role: 'tester', id: m.tester },
|
|
302
|
+
{ role: 'reviewer', id: m.reviewer },
|
|
303
|
+
].map((x) => ({ ...x, name: this.manager.store.get(x.id)?.name ?? null }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* One problem, a team of agents. Needs a repository URL, a git folder or a project (the team
|
|
308
|
+
* works in one worktree). critics: 0-2. models / engines: per role, e.g. { reviewer: "opus" }.
|
|
309
|
+
*/
|
|
310
|
+
create({ intent, name, critics, maxRounds, maxLoops, gapRounds, testCommand, models, engines, mode, ...opts } = {}) {
|
|
311
|
+
intent = String(intent ?? '').trim();
|
|
312
|
+
if (!intent) throw err(400, 'Describe the problem for the team');
|
|
313
|
+
const defaults = this.config.teams ?? {};
|
|
314
|
+
const n = Number(critics ?? defaults.critics ?? 1);
|
|
315
|
+
if (!Number.isInteger(n) || n < 0 || n > 2) throw err(400, 'critics must be 0, 1 or 2');
|
|
316
|
+
// "lead": one session whose sub-agents play the roles (needs an engine with sub-agents);
|
|
317
|
+
// "agents": one agent per role, each on any engine.
|
|
318
|
+
const asked = mode;
|
|
319
|
+
mode ??= defaults.mode ?? 'lead';
|
|
320
|
+
if (!['lead', 'agents'].includes(mode)) throw err(400, 'mode must be "lead" or "agents"');
|
|
321
|
+
const roleEngines = { ...defaults.engines, ...engines };
|
|
322
|
+
const leadEngine = engineOf(opts.engine || this.config.engine || 'claude');
|
|
323
|
+
const noSubagents = !leadEngine.supports.subagents ? `${leadEngine.label} has no sub-agents` : Object.values(roleEngines).some(Boolean) ? 'Roles on other engines need agents of their own' : null;
|
|
324
|
+
if (mode === 'lead' && noSubagents) {
|
|
325
|
+
if (asked === 'lead') throw err(400, `${noSubagents}: use separate agents (mode "agents")`);
|
|
326
|
+
mode = 'agents';
|
|
327
|
+
}
|
|
328
|
+
const limit = (v, d) => Math.min(10, Math.max(1, Number.isInteger(Number(v)) && Number(v) > 0 ? Number(v) : d));
|
|
329
|
+
const id = `t${crypto.randomBytes(3).toString('hex')}`;
|
|
330
|
+
const slug = (name || intent).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24).replace(/-+$/, '') || 'team';
|
|
331
|
+
// No repository yet: a new project in a folder of its own (or the new folder given).
|
|
332
|
+
let newProject = null;
|
|
333
|
+
if (!opts.repo && !opts.project) {
|
|
334
|
+
let target = opts.dir?.trim() ? path.resolve(expandHome(opts.dir.trim())) : null;
|
|
335
|
+
if (!target) {
|
|
336
|
+
const root = path.resolve(expandHome(defaults.newProjectsDir ?? path.join(paths.home, 'projects')));
|
|
337
|
+
// Whole words, up to 40 characters: the project's folder name.
|
|
338
|
+
const full = (name || intent).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
|
|
339
|
+
const folder = full.length <= 40 ? full : full.slice(0, 41).replace(/-[^-]*$/, '') || full.slice(0, 40);
|
|
340
|
+
target = path.join(root, folder);
|
|
341
|
+
for (let i = 2; fs.existsSync(target); i++) target = path.join(root, `${folder}-${i}`);
|
|
342
|
+
}
|
|
343
|
+
// A repository is used as it is; a new or empty folder becomes one.
|
|
344
|
+
const repo = startRepo(target);
|
|
345
|
+
if (repo.created) newProject = repo.dir;
|
|
346
|
+
opts.dir = repo.dir;
|
|
347
|
+
}
|
|
348
|
+
const dir = teamDir(id);
|
|
349
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
350
|
+
const settings = {
|
|
351
|
+
critics: n,
|
|
352
|
+
maxRounds: limit(maxRounds, defaults.maxRounds ?? 3),
|
|
353
|
+
maxLoops: limit(maxLoops, defaults.maxLoops ?? 3),
|
|
354
|
+
// Gap rounds the team starts without asking, for what the reviewer says is still missing;
|
|
355
|
+
// 0 (the default): it stops and asks the person before each one.
|
|
356
|
+
gapRounds: Math.min(10, Math.max(0, Number.isInteger(Number(gapRounds ?? defaults.gapRounds ?? 0)) ? Number(gapRounds ?? defaults.gapRounds ?? 0) : 0)),
|
|
357
|
+
testCommand: testCommand?.trim() || defaults.testCommand || null,
|
|
358
|
+
models: { ...defaults.models, ...models },
|
|
359
|
+
engines: roleEngines,
|
|
360
|
+
};
|
|
361
|
+
const team = { id, mode, name: name?.trim() || slug, intent, dir, newProject, createdAt: now(), updatedAt: now(), settings, stage: 'analyze', round: 1, loop: 1, extraRounds: 0, extraLoops: 0, quest: 1, quests: [], gapRound: 0, waiting: [], history: [], blocked: null, outcome: null, members: { critics: [] } };
|
|
362
|
+
this.store.data.teams[id] = team;
|
|
363
|
+
|
|
364
|
+
const base = { ...opts, intent, start: false, planReview: false };
|
|
365
|
+
if (mode === 'lead') {
|
|
366
|
+
team.members = { lead: this.manager.create({ ...base, name: slug, worktree: true, model: settings.models.lead || opts.model, team: { id, role: 'lead' } }).id };
|
|
367
|
+
team.seen = {};
|
|
368
|
+
fs.mkdirSync(this.questPath(team, ''), { recursive: true });
|
|
369
|
+
this.send(team, team.members.lead, `This is quest 1. Start the team: ask the analyst for the analysis (${this.questPath(team, 'analysis-1.md')}).`);
|
|
370
|
+
this.save(team);
|
|
371
|
+
return this.view(id);
|
|
372
|
+
}
|
|
373
|
+
const make = (role, extra = {}) =>
|
|
374
|
+
this.manager.create({
|
|
375
|
+
...base,
|
|
376
|
+
model: settings.models[role] || opts.model,
|
|
377
|
+
engine: settings.engines[role] || opts.engine,
|
|
378
|
+
team: { id, role, ...(extra.n ? { n: extra.n } : {}) },
|
|
379
|
+
...extra,
|
|
380
|
+
}).id;
|
|
381
|
+
// The implementer owns the worktree and branch; everyone else works in it.
|
|
382
|
+
const owner = make('implementer', { name: slug, worktree: true });
|
|
383
|
+
team.members.implementer = owner;
|
|
384
|
+
const shared = async (agent, { progress }) => {
|
|
385
|
+
progress({ phase: 'Waiting for the team workspace' });
|
|
386
|
+
await this.manager.preparing.get(owner)?.done.catch(() => {});
|
|
387
|
+
const o = this.manager.store.get(owner);
|
|
388
|
+
if (!o?.workdir) throw new Error(`The team workspace could not be prepared${o?.error ? `: ${o.error}` : ''}`);
|
|
389
|
+
return { workdir: o.workdir, repo: o.repo, worktree: false, sharedWith: owner };
|
|
390
|
+
};
|
|
391
|
+
team.members.analyst = make('analyst', { name: `${slug}-analyst`, workspace: shared });
|
|
392
|
+
for (let i = 1; i <= n; i++) team.members.critics.push(make('critic', { name: `${slug}-critic${n > 1 ? i : ''}`, workspace: shared, n: i }));
|
|
393
|
+
team.members.tester = make('tester', { name: `${slug}-tester`, workspace: shared });
|
|
394
|
+
team.members.reviewer = make('reviewer', { name: `${slug}-reviewer`, workspace: shared });
|
|
395
|
+
|
|
396
|
+
this.assign(team, [{ role: 'analyst', member: team.members.analyst, kind: 'analysis', file: 'analysis-1.md', text: `Study the problem and the code, then write your analysis to ${this.path(team, 'analysis-1.md')}. End it with "verdict: ready".` }]);
|
|
397
|
+
this.save(team);
|
|
398
|
+
return this.view(id);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Where the current quest keeps a handoff file. */
|
|
402
|
+
path(team, file) {
|
|
403
|
+
return path.join(team.dir, this.rel(team, file));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A handoff file's name within the team folder: a lead's quests each have a folder; a team of
|
|
408
|
+
* separate agents keeps its first quest in the team folder itself, and gap rounds in folders.
|
|
409
|
+
*/
|
|
410
|
+
rel(team, file) {
|
|
411
|
+
const q = team.quest ?? 1;
|
|
412
|
+
return team.mode === 'lead' || q > 1 ? path.join(`quest-${q}`, file) : file;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
questPath(team, file) {
|
|
416
|
+
return this.path(team, file);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Give out the next step: each task names the member, the file it writes and the message it gets. */
|
|
420
|
+
assign(team, tasks) {
|
|
421
|
+
team.waiting = tasks.map(({ role, member, kind, file, n, text }) => ({ role, member, kind, file, ...(n ? { n } : {}), text, since: now() }));
|
|
422
|
+
for (const t of tasks) this.send(team, t.member, t.text);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A member's first message starts its session (with the problem); later ones are typed into it. */
|
|
426
|
+
send(team, member, text) {
|
|
427
|
+
const a = this.manager.store.get(member);
|
|
428
|
+
if (!a) throw err(409, 'A team member was deleted');
|
|
429
|
+
if (!a.lastStartedAt && !this.manager.runtimes.get(member)?.pty) {
|
|
430
|
+
this.manager.patch(member, { kickoff: `Problem to solve:\n${team.intent}\n\n${text}`, desired: 'running' });
|
|
431
|
+
// Still preparing: the workspace setup starts it, since it is now wanted running.
|
|
432
|
+
if (a.status !== 'preparing' && a.workdir) this.manager.start(member);
|
|
433
|
+
} else {
|
|
434
|
+
this.manager.tell(member, text, { source: 'team' });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Read finished handoff files and move the team on. */
|
|
439
|
+
poll() {
|
|
440
|
+
for (const team of Object.values(this.store.data.teams)) {
|
|
441
|
+
// A lead stays between quests: its team is followed until it is stopped.
|
|
442
|
+
if (team.mode === 'lead' ? team.stage === 'stopped' : !ACTIVE.has(team.stage) || !team.waiting.length) continue;
|
|
443
|
+
try {
|
|
444
|
+
if (team.mode === 'lead') this.observe(team);
|
|
445
|
+
else this.check(team);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
console.error(`[agentmgr] team ${team.id}: ${e.message}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A lead's team: read the handoff files its sub-agents write, in the order they were written,
|
|
454
|
+
* and follow along. The lead runs the pipeline; this only records it and notices the stops.
|
|
455
|
+
*/
|
|
456
|
+
observe(team) {
|
|
457
|
+
let changed = false;
|
|
458
|
+
// A new quest folder with its request: the lead took on new work.
|
|
459
|
+
let top = team.quest ?? 1;
|
|
460
|
+
try {
|
|
461
|
+
for (const d of fs.readdirSync(team.dir)) {
|
|
462
|
+
const n = Number(/^quest-(\d+)$/.exec(d)?.[1]);
|
|
463
|
+
if (n > top && fs.existsSync(path.join(team.dir, d, 'request.md'))) top = n;
|
|
464
|
+
}
|
|
465
|
+
} catch {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (top > (team.quest ?? 1)) {
|
|
469
|
+
this.nextQuest(team, top);
|
|
470
|
+
changed = true;
|
|
471
|
+
}
|
|
472
|
+
let files;
|
|
473
|
+
try {
|
|
474
|
+
files = fs.readdirSync(this.questPath(team, '')).map((name) => ({ name, mtime: fs.statSync(this.questPath(team, name)).mtimeMs }));
|
|
475
|
+
} catch {
|
|
476
|
+
if (changed) this.save(team);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// Oldest first; files written at the same moment in pipeline order.
|
|
480
|
+
const rank = (name) => {
|
|
481
|
+
const m = /^(analysis|critique|implementation|test|review)-(\d+)/.exec(name);
|
|
482
|
+
if (!m) return 1e6;
|
|
483
|
+
const step = { analysis: 0, critique: 1, implementation: 0, test: 1, review: 2 }[m[1]];
|
|
484
|
+
return (['analysis', 'critique'].includes(m[1]) ? 0 : 1000) + Number(m[2]) * 10 + step;
|
|
485
|
+
};
|
|
486
|
+
for (const { name, mtime } of files.sort((a, b) => a.mtime - b.mtime || rank(a.name) - rank(b.name))) {
|
|
487
|
+
const rel = `quest-${team.quest}/${name}`;
|
|
488
|
+
if (team.seen[rel] === mtime || Date.now() - mtime < 1500) continue;
|
|
489
|
+
const hit = FILES.map(([re, kind, role]) => [re.exec(name), kind, role]).find(([m]) => m);
|
|
490
|
+
if (!hit) continue;
|
|
491
|
+
const [m, kind, role] = hit;
|
|
492
|
+
const text = fs.readFileSync(this.questPath(team, name), 'utf8');
|
|
493
|
+
const verdict = readVerdict(text, VERDICTS[kind]);
|
|
494
|
+
if (!verdict) continue; // still being written, or the lead asks for the verdict line
|
|
495
|
+
team.seen[rel] = mtime;
|
|
496
|
+
const num = Number(m[1] ?? 0);
|
|
497
|
+
const coverage = kind === 'review' ? readCoverage(text) : null;
|
|
498
|
+
const entry = { at: now(), stage: team.stage, round: kind === 'analysis' || kind === 'critique' ? num : team.round, loop: ['implementation', 'test', 'review'].includes(kind) ? num : team.loop, role, ...(kind === 'critique' ? { n: Number(m[2]) } : {}), member: team.members.lead, file: rel, verdict, ...(coverage ? { coverage } : {}), summary: summarize(text) };
|
|
499
|
+
// A rewritten file replaces its entry.
|
|
500
|
+
team.history = team.history.filter((h) => h.file !== rel).concat(entry);
|
|
501
|
+
if (kind === 'analysis') team.round = Math.max(team.round, num);
|
|
502
|
+
if (['implementation', 'test', 'review'].includes(kind)) team.loop = Math.max(team.loop, num);
|
|
503
|
+
team.stage = this.leadStage(team, kind, verdict, num, coverage);
|
|
504
|
+
if (kind === 'blocked') team.blocked = { reason: entry.summary || 'The team stopped', at: team.history.some((h) => h.role === 'implementer') ? 'loops' : 'discuss', since: now() };
|
|
505
|
+
if (kind === 'final') Object.assign(team, { outcome: 'pr', finishedAt: now() });
|
|
506
|
+
changed = true;
|
|
507
|
+
}
|
|
508
|
+
if (changed) this.save(team);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The lead started quest n: file the current one under earlier quests and follow the new one. */
|
|
512
|
+
nextQuest(team, n) {
|
|
513
|
+
team.quests ??= [];
|
|
514
|
+
team.quests.push({
|
|
515
|
+
n: team.quest ?? 1,
|
|
516
|
+
request: team.request ?? team.intent,
|
|
517
|
+
stage: team.stage,
|
|
518
|
+
outcome: team.outcome ?? ({ ready: 'approved, left as is', gaps: 'approved, with gaps', done: 'done' }[team.stage] ?? `left at ${team.stage}`),
|
|
519
|
+
history: team.history,
|
|
520
|
+
finishedAt: now(),
|
|
521
|
+
});
|
|
522
|
+
let request = '';
|
|
523
|
+
let gap = false;
|
|
524
|
+
try {
|
|
525
|
+
const raw = fs.readFileSync(path.join(team.dir, `quest-${n}`, 'request.md'), 'utf8');
|
|
526
|
+
// A gap round keeps its heading ("Gap round 2 of 3"); other requests drop theirs.
|
|
527
|
+
gap = /^#+\s*gap round\b/i.test(raw.trimStart());
|
|
528
|
+
request = (gap ? raw.trimStart().replace(/^#+\s*/, '') : raw.replace(/^#.*\n/, '')).trim();
|
|
529
|
+
} catch {}
|
|
530
|
+
// Gap rounds count up within one request; the person's next request starts again.
|
|
531
|
+
team.gapRound = gap ? (team.gapRound ?? 0) + 1 : 0;
|
|
532
|
+
Object.assign(team, { quest: n, request: request || `Quest ${n}`, stage: 'analyze', round: 1, loop: 1, extraRounds: 0, extraLoops: 0, waiting: [], history: [], blocked: null, outcome: null, finishedAt: null });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Give the lead new work from the team view or the CLI: it becomes the next quest. */
|
|
536
|
+
ask(id, text) {
|
|
537
|
+
const team = this.get(id);
|
|
538
|
+
if (team.mode !== 'lead') throw err(409, 'Only a team with a lead takes new requests; start a new team');
|
|
539
|
+
if (team.stage === 'stopped') throw err(409, 'The team is stopped');
|
|
540
|
+
text = String(text ?? '').trim();
|
|
541
|
+
if (!text) throw err(400, 'Say what the team should do');
|
|
542
|
+
this.manager.tell(team.members.lead, `New work from the person, for the next quest (${team.quest + 1}):\n${text}`, { source: 'team' });
|
|
543
|
+
return this.view(id);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Where a lead's team stands after a handoff file. */
|
|
547
|
+
leadStage(team, kind, verdict, num, coverage) {
|
|
548
|
+
switch (kind) {
|
|
549
|
+
case 'analysis':
|
|
550
|
+
return team.settings.critics ? 'discuss' : 'implement';
|
|
551
|
+
case 'critique': {
|
|
552
|
+
const round = team.history.filter((h) => h.role === 'critic' && h.round === num);
|
|
553
|
+
return round.length >= team.settings.critics && round.every((h) => h.verdict === 'agree') ? 'implement' : 'discuss';
|
|
554
|
+
}
|
|
555
|
+
case 'implementation':
|
|
556
|
+
return verdict === 'done' ? 'test' : 'implement';
|
|
557
|
+
case 'test':
|
|
558
|
+
return verdict === 'pass' ? 'review' : 'implement';
|
|
559
|
+
case 'review':
|
|
560
|
+
if (verdict !== 'approve') return 'implement';
|
|
561
|
+
// Parts still missing and gap rounds left: the lead starts the next round itself.
|
|
562
|
+
return coverage === 'partial' && (team.gapRound ?? 0) < (team.settings.gapRounds ?? 0) ? 'gaps' : 'ready';
|
|
563
|
+
case 'blocked':
|
|
564
|
+
return 'blocked';
|
|
565
|
+
case 'final':
|
|
566
|
+
return 'done';
|
|
567
|
+
default:
|
|
568
|
+
return team.stage;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Tell a lead's team what the person decided; the lead carries it out. */
|
|
573
|
+
toLead(team, text, stage) {
|
|
574
|
+
team.stage = stage;
|
|
575
|
+
team.blocked = null;
|
|
576
|
+
this.manager.tell(team.members.lead, text, { source: 'team' });
|
|
577
|
+
this.save(team);
|
|
578
|
+
return this.view(team.id);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
check(team) {
|
|
582
|
+
let changed = false;
|
|
583
|
+
for (const w of team.waiting) {
|
|
584
|
+
if (w.verdict) continue;
|
|
585
|
+
let st;
|
|
586
|
+
try {
|
|
587
|
+
st = fs.statSync(this.path(team, w.file));
|
|
588
|
+
} catch {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// Wait until the writer is done: the file has settled and the agent is idle.
|
|
592
|
+
if (Date.now() - st.mtimeMs < 1500) continue;
|
|
593
|
+
const rt = this.manager.runtimes.get(w.member);
|
|
594
|
+
if (rt?.busy || this.manager.outbox.get(w.member)?.length) continue;
|
|
595
|
+
const text = fs.readFileSync(this.path(team, w.file), 'utf8');
|
|
596
|
+
const verdict = readVerdict(text, VERDICTS[w.kind]);
|
|
597
|
+
if (!verdict) {
|
|
598
|
+
if (!w.nagged && Date.now() - st.mtimeMs > 20_000) {
|
|
599
|
+
w.nagged = true;
|
|
600
|
+
changed = true;
|
|
601
|
+
this.manager.tell(w.member, `${this.path(team, w.file)} has no verdict line I can read. End it with one of: ${VERDICTS[w.kind].map((v) => `"verdict: ${v}"`).join(', ')}.`, { source: 'team' });
|
|
602
|
+
}
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const coverage = w.kind === 'review' ? readCoverage(text) : null;
|
|
606
|
+
Object.assign(w, { verdict, summary: summarize(text), at: now(), ...(coverage ? { coverage } : {}) });
|
|
607
|
+
team.history.push({ at: w.at, stage: team.stage, round: team.round, loop: team.loop, role: w.role, n: w.n, member: w.member, file: this.rel(team, w.file), verdict, ...(coverage ? { coverage } : {}), summary: w.summary });
|
|
608
|
+
changed = true;
|
|
609
|
+
}
|
|
610
|
+
if (team.waiting.every((w) => w.verdict)) this.advance(team);
|
|
611
|
+
else if (changed) this.save(team);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
advance(team) {
|
|
615
|
+
const done = team.waiting;
|
|
616
|
+
const f = (file) => this.path(team, file);
|
|
617
|
+
const s = team.settings;
|
|
618
|
+
switch (team.stage) {
|
|
619
|
+
case 'analyze': {
|
|
620
|
+
if (!s.critics) return this.toImplement(team);
|
|
621
|
+
team.stage = 'discuss';
|
|
622
|
+
const analysis = `analysis-${team.round}.md`;
|
|
623
|
+
this.assign(
|
|
624
|
+
team,
|
|
625
|
+
team.members.critics.map((member, i) => {
|
|
626
|
+
const file = `critique-${team.round}-${i + 1}.md`;
|
|
627
|
+
const text =
|
|
628
|
+
team.round === 1
|
|
629
|
+
? `The analyst's analysis is in ${f(analysis)}. Review it and write your critique to ${f(file)}.`
|
|
630
|
+
: `The analyst revised the analysis after your critique ${f(`critique-${team.round - 1}-${i + 1}.md`)}: ${f(analysis)}. Check whether your points were addressed and write your critique to ${f(file)}.`;
|
|
631
|
+
return { role: 'critic', n: i + 1, member, kind: 'critique', file, text: `${text} End it with "verdict: agree", "verdict: changes" or "verdict: questions".` };
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
case 'discuss': {
|
|
637
|
+
if (done.every((w) => w.verdict === 'agree')) return this.toImplement(team);
|
|
638
|
+
if (team.round >= s.maxRounds + team.extraRounds) return this.block(team, `The analyst and the critics did not agree in ${team.round} round${team.round > 1 ? 's' : ''}`, 'discuss');
|
|
639
|
+
this.reviseAnalysis(team);
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
case 'implement': {
|
|
643
|
+
const w = done[0];
|
|
644
|
+
if (w.verdict === 'blocked') return this.block(team, `The implementer is blocked: ${w.summary}`, 'implement');
|
|
645
|
+
team.stage = 'test';
|
|
646
|
+
const file = `test-${team.loop}.md`;
|
|
647
|
+
this.assign(team, [{
|
|
648
|
+
role: 'tester', member: team.members.tester, kind: 'test', file,
|
|
649
|
+
text: `The implementer finished round ${team.loop}: notes in ${f(`implementation-${team.loop}.md`)}; the agreed analysis is ${f('analysis.md')}.${s.testCommand ? ` The project's test command is: ${s.testCommand}` : ''} Test it and write your test report to ${f(file)}. End it with "verdict: pass" or "verdict: fail".`,
|
|
650
|
+
}]);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
case 'test': {
|
|
654
|
+
if (done[0].verdict === 'fail') return this.sendBackFrom(team, 'tester', done[0].file);
|
|
655
|
+
team.stage = 'review';
|
|
656
|
+
const file = `review-${team.loop}.md`;
|
|
657
|
+
const sha = this.manager.store.get(team.members.implementer)?.baseRef?.split(' @ ')[1];
|
|
658
|
+
this.assign(team, [{
|
|
659
|
+
role: 'reviewer', member: team.members.reviewer, kind: 'review', file,
|
|
660
|
+
text: `Review the change on this branch (${sha ? `git diff ${sha.slice(0, 12)}...HEAD` : 'against its base branch'}). Read the analysis ${f('analysis.md')}, the implementation notes ${f(`implementation-${team.loop}.md`)} and the test report ${f(`test-${team.loop}.md`)}. Check coverage against ${team.quest > 1 ? `the request in ${f('request.md')}` : 'the whole problem'}. Write your review to ${f(file)}, with its "## Gaps" section and, just before the verdict, "coverage: complete" or "coverage: partial". End it with "verdict: approve" or "verdict: changes".`,
|
|
661
|
+
}]);
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
case 'review': {
|
|
665
|
+
if (done[0].verdict === 'changes') return this.sendBackFrom(team, 'reviewer', done[0].file);
|
|
666
|
+
if (done[0].coverage === 'partial' && team.gapRound < s.gapRounds) return this.gapRound(team, { auto: true });
|
|
667
|
+
team.stage = 'ready';
|
|
668
|
+
team.waiting = [];
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
case 'finalizing': {
|
|
672
|
+
team.stage = 'done';
|
|
673
|
+
team.outcome = 'pr';
|
|
674
|
+
team.finishedAt = now();
|
|
675
|
+
team.waiting = [];
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
default:
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
this.save(team);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
reviseAnalysis(team, note) {
|
|
685
|
+
const f = (file) => this.path(team, file);
|
|
686
|
+
const prev = team.round;
|
|
687
|
+
team.round++;
|
|
688
|
+
team.stage = 'analyze';
|
|
689
|
+
const critiques = team.members.critics.map((_, i) => {
|
|
690
|
+
const file = `critique-${prev}-${i + 1}.md`;
|
|
691
|
+
const v = team.history.findLast((h) => h.file === this.rel(team, file))?.verdict;
|
|
692
|
+
return `- ${f(file)}${v ? ` (${v})` : ''}`;
|
|
693
|
+
});
|
|
694
|
+
const file = `analysis-${team.round}.md`;
|
|
695
|
+
this.assign(team, [{
|
|
696
|
+
role: 'analyst', member: team.members.analyst, kind: 'analysis', file,
|
|
697
|
+
text: `${critiques.length ? `The critics replied to ${f(`analysis-${prev}.md`)}:\n${critiques.join('\n')}\nRead them and address every point: change the analysis, or say in it why you disagree.` : 'Revise your analysis.'}${note ? `\n\nFrom the person:\n${note}` : ''}\n\nWrite the revised analysis to ${f(file)} and end it with "verdict: ready".`,
|
|
698
|
+
}]);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
toImplement(team) {
|
|
702
|
+
const latest = this.path(team, `analysis-${team.round}.md`);
|
|
703
|
+
if (fs.existsSync(latest)) fs.copyFileSync(latest, this.path(team, 'analysis.md'));
|
|
704
|
+
team.stage = 'implement';
|
|
705
|
+
const file = `implementation-${team.loop}.md`;
|
|
706
|
+
this.assign(team, [{
|
|
707
|
+
role: 'implementer', member: team.members.implementer, kind: 'implementation', file,
|
|
708
|
+
text: `The team agreed on an analysis: ${this.path(team, 'analysis.md')}${team.settings.critics ? ' (the critiques are next to it)' : ''}. Implement it in this working copy and commit on the current branch. Don't push or open a pull request yet. Then write your implementation notes to ${this.path(team, file)} and end them with "verdict: done", or "verdict: blocked" with the reason.`,
|
|
709
|
+
}]);
|
|
710
|
+
this.save(team);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The tester or the reviewer sent the work back: another implementation round, within the limit. */
|
|
714
|
+
sendBackFrom(team, role, file) {
|
|
715
|
+
if (team.loop >= team.settings.maxLoops + team.extraLoops) return this.block(team, `The ${role} still sends the work back after ${team.loop} round${team.loop > 1 ? 's' : ''}`, 'loops');
|
|
716
|
+
this.nextLoop(team, `The ${role} sent the work back: ${this.path(team, file)}.`);
|
|
717
|
+
this.save(team);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
nextLoop(team, why) {
|
|
721
|
+
team.loop++;
|
|
722
|
+
team.stage = 'implement';
|
|
723
|
+
const file = `implementation-${team.loop}.md`;
|
|
724
|
+
this.assign(team, [{
|
|
725
|
+
role: 'implementer', member: team.members.implementer, kind: 'implementation', file,
|
|
726
|
+
text: `${why}\n\nMake the changes, commit, and write your implementation notes for this round to ${this.path(team, file)}, ending with "verdict: done" (or "verdict: blocked" with the reason).`,
|
|
727
|
+
}]);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The latest review of this quest that lists gaps, if any: { file, text }. */
|
|
731
|
+
lastGaps(team) {
|
|
732
|
+
const h = team.history.findLast((x) => x.role === 'reviewer');
|
|
733
|
+
if (h?.coverage !== 'partial') return null;
|
|
734
|
+
try {
|
|
735
|
+
return { file: path.join(team.dir, h.file), text: fs.readFileSync(path.join(team.dir, h.file), 'utf8') };
|
|
736
|
+
} catch {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* A team of separate agents builds what the reviewer says is still missing: the next quest,
|
|
743
|
+
* from a new analysis, on the same branch. `auto`: started by the team within its gap rounds.
|
|
744
|
+
*/
|
|
745
|
+
gapRound(team, { auto = false, note } = {}) {
|
|
746
|
+
const review = this.lastGaps(team);
|
|
747
|
+
const k = (team.gapRound ?? 0) + 1;
|
|
748
|
+
const base = team.quests?.length && team.request?.match(/^gap round\b/i) ? team.quests.findLast((q) => !/^gap round\b/i.test(q.request))?.request : null;
|
|
749
|
+
const request = base ?? team.request ?? team.intent;
|
|
750
|
+
const title = `Gap round ${k}`;
|
|
751
|
+
const gaps = review ? gapsOf(review.text) : '';
|
|
752
|
+
const n = (team.quest ?? 1) + 1;
|
|
753
|
+
if (team.stage === 'review') team.stage = 'gaps';
|
|
754
|
+
const dir = path.join(team.dir, `quest-${n}`);
|
|
755
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
756
|
+
fs.writeFileSync(
|
|
757
|
+
path.join(dir, 'request.md'),
|
|
758
|
+
`# ${title}\n\nBuild what is still missing from this request:\n\n${request.replace(/^gap round[^\n]*\n+/i, '')}\n\n## Gaps the reviewer found\n\n${gaps || (review ? `See ${review.file}.` : 'None listed.')}\n${note ? `\n## From the person\n\n${note}\n` : ''}`,
|
|
759
|
+
);
|
|
760
|
+
this.nextQuest(team, n);
|
|
761
|
+
const f = (file) => this.path(team, file);
|
|
762
|
+
this.assign(team, [{
|
|
763
|
+
role: 'analyst', member: team.members.analyst, kind: 'analysis', file: 'analysis-1.md',
|
|
764
|
+
text: `${title}: the reviewer approved the work so far but found parts of the request not built yet${review ? ` (${review.file}, section Gaps)` : ''}. This round builds them, on the same branch: the request is in ${f('request.md')}. Study the code as it is now and write an analysis for what is missing to ${f('analysis-1.md')}. End it with "verdict: ready".`,
|
|
765
|
+
}]);
|
|
766
|
+
this.save(team);
|
|
767
|
+
return this.view(team.id);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The person asks for another round on what the last review says is missing (or on their note). */
|
|
771
|
+
fixGaps(id, note) {
|
|
772
|
+
const team = this.get(id);
|
|
773
|
+
if (!['ready', 'done'].includes(team.stage)) throw err(409, "The team is busy; wait until it's done or waiting for you");
|
|
774
|
+
note = note?.trim() || null;
|
|
775
|
+
if (!this.lastGaps(team) && !note) throw err(400, "The last review lists no gaps: say what's missing");
|
|
776
|
+
if (team.mode === 'lead') {
|
|
777
|
+
const review = this.lastGaps(team);
|
|
778
|
+
return this.toLead(
|
|
779
|
+
team,
|
|
780
|
+
`The person wants a gap round now: start the next quest with ${path.join(team.dir, `quest-${team.quest + 1}`, 'request.md')}, headed "# Gap round ${(team.gapRound ?? 0) + 1}", for ${review ? `the gaps in ${review.file}` : 'what the person says is missing'}, and run the steps for it.${note ? `\n\nFrom the person:\n${note}` : ''}`,
|
|
781
|
+
'gaps',
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return this.gapRound(team, { note });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
block(team, reason, at) {
|
|
788
|
+
team.stage = 'blocked';
|
|
789
|
+
team.blocked = { reason, at, since: now() };
|
|
790
|
+
team.waiting = [];
|
|
791
|
+
this.save(team);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ------------------------------------------------------------ the person's calls
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* After a block: "continue" gives the team one more round (with an optional note), "skip"
|
|
798
|
+
* moves past the disagreement (implement the latest analysis, or accept the work as is).
|
|
799
|
+
*/
|
|
800
|
+
resume(id, { action = 'continue', note } = {}) {
|
|
801
|
+
const team = this.get(id);
|
|
802
|
+
if (team.stage !== 'blocked') throw err(409, 'The team is not waiting on you');
|
|
803
|
+
note = note?.trim() || null;
|
|
804
|
+
const at = team.blocked.at;
|
|
805
|
+
if (at === 'member') throw err(409, 'A member was deleted; stop the team instead');
|
|
806
|
+
if (team.mode === 'lead') {
|
|
807
|
+
const from = note ? `\n\nFrom the person:\n${note}` : '';
|
|
808
|
+
if (action === 'skip') {
|
|
809
|
+
if (at === 'discuss') return this.toLead(team, `The person read the disagreement and wants the latest analysis implemented as it is: copy it to ${this.questPath(team, 'analysis.md')} and go on with the implementer.${from}`, 'implement');
|
|
810
|
+
return this.toLead(team, `The person accepted the work as it is. Wait for their decision; don't push or open a pull request yet.${from}`, 'ready');
|
|
811
|
+
}
|
|
812
|
+
if (action !== 'continue') throw err(400, 'action must be "continue" or "skip"');
|
|
813
|
+
return this.toLead(team, `The person read why the team stopped and wants one more ${at === 'discuss' ? 'analysis round' : 'implementation round'}, beyond the limit.${from}\n\nContinue from where the team stopped.`, at === 'discuss' ? 'analyze' : 'implement');
|
|
814
|
+
}
|
|
815
|
+
team.blocked = null;
|
|
816
|
+
if (action === 'skip') {
|
|
817
|
+
if (at === 'discuss') {
|
|
818
|
+
this.toImplement(team);
|
|
819
|
+
return this.view(id);
|
|
820
|
+
}
|
|
821
|
+
if (at === 'loops') {
|
|
822
|
+
team.stage = 'ready';
|
|
823
|
+
this.save(team);
|
|
824
|
+
return this.view(id);
|
|
825
|
+
}
|
|
826
|
+
throw err(400, 'Nothing to skip: the implementer is blocked; continue with a note');
|
|
827
|
+
}
|
|
828
|
+
if (action !== 'continue') throw err(400, 'action must be "continue" or "skip"');
|
|
829
|
+
if (at === 'discuss') {
|
|
830
|
+
team.extraRounds++;
|
|
831
|
+
this.reviseAnalysis(team, note);
|
|
832
|
+
} else {
|
|
833
|
+
if (at === 'loops') team.extraLoops++;
|
|
834
|
+
const last = team.history.findLast((h) => ['test', 'review', 'implement'].includes(h.stage));
|
|
835
|
+
this.nextLoop(team, `${last ? `Latest: ${path.join(team.dir, last.file)} (${last.verdict}).` : ''}${note ? `\n\nFrom the person:\n${note}` : ''}`.trim());
|
|
836
|
+
}
|
|
837
|
+
this.save(team);
|
|
838
|
+
return this.view(id);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** At the end: send the approved work back with what the person wants changed. */
|
|
842
|
+
sendBack(id, note) {
|
|
843
|
+
const team = this.get(id);
|
|
844
|
+
if (team.stage !== 'ready') throw err(409, 'The team has nothing waiting for your review');
|
|
845
|
+
note = note?.trim();
|
|
846
|
+
if (!note) throw err(400, 'Say what should change');
|
|
847
|
+
if (team.mode === 'lead') {
|
|
848
|
+
return this.toLead(team, `The person reviewed the team's work and sent it back:\n${note}\n\nStart implementation round ${team.loop + 1} with this (beyond the limit if needed), then test and review again, and stop for the person when the reviewer approves.`, 'implement');
|
|
849
|
+
}
|
|
850
|
+
team.extraLoops++;
|
|
851
|
+
this.nextLoop(team, `The person reviewed the team's work and sent it back:\n${note}`);
|
|
852
|
+
this.save(team);
|
|
853
|
+
return this.view(id);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Accept the approved work. mode "pr": the implementer pushes and opens a pull request;
|
|
858
|
+
* "publish" (no GitHub repository yet): it creates a private one first; "merge": the branch is
|
|
859
|
+
* fast-forwarded into the local base branch; "keep": the branch stays as it is.
|
|
860
|
+
*/
|
|
861
|
+
async finalize(id, { mode, pr } = {}) {
|
|
862
|
+
const team = this.get(id);
|
|
863
|
+
if (team.stage !== 'ready') throw err(409, 'The team has nothing waiting for your review');
|
|
864
|
+
mode ??= pr === false ? 'keep' : 'pr';
|
|
865
|
+
if (!['pr', 'publish', 'merge', 'keep'].includes(mode)) throw err(400, 'mode must be pr, publish, merge or keep');
|
|
866
|
+
const owner = this.manager.store.get(team.members.implementer ?? team.members.lead);
|
|
867
|
+
if (mode === 'keep' || mode === 'merge') {
|
|
868
|
+
if (mode === 'merge') await this.merge(owner);
|
|
869
|
+
Object.assign(team, { stage: 'done', outcome: mode === 'merge' ? 'merged' : 'kept', finishedAt: now() });
|
|
870
|
+
this.save(team);
|
|
871
|
+
return this.view(id);
|
|
872
|
+
}
|
|
873
|
+
const remote = owner?.workdir && (await git.hasRemote(owner.workdir));
|
|
874
|
+
if (mode === 'pr' && !remote) throw err(409, 'This project has no remote to open a pull request on: publish it to GitHub, merge into main, or keep the branch');
|
|
875
|
+
const files = `the files are in ${this.questPath(team, '')}`;
|
|
876
|
+
const publish = !remote
|
|
877
|
+
? `This project has no GitHub repository yet. Create a private one named "${path.basename(owner?.sourceDir ?? team.name)}" with \`gh repo create <name> --private --source ${owner?.sourceDir ?? '.'} --remote origin\`, push the base branch and this branch to it, then open`
|
|
878
|
+
: 'Push the branch and open';
|
|
879
|
+
const finalText = `The person approved the team's work. ${publish} a pull request with a title from the problem and a body that summarizes the analysis, the changes, the test report and the review (${files}). Then write the pull request's link to ${this.questPath(team, 'final.md')} and end it with "verdict: done".`;
|
|
880
|
+
if (team.mode === 'lead') return this.toLead(team, finalText, 'finalizing');
|
|
881
|
+
team.stage = 'finalizing';
|
|
882
|
+
this.assign(team, [{
|
|
883
|
+
role: 'implementer', member: team.members.implementer, kind: 'final', file: 'final.md',
|
|
884
|
+
text: `The team's work is approved. ${publish} a pull request with a title from the problem and a body that summarizes the analysis, the changes, the test report and the review (${files}). Then write the pull request's link to ${this.path(team, 'final.md')} and end it with "verdict: done".`,
|
|
885
|
+
}]);
|
|
886
|
+
this.save(team);
|
|
887
|
+
return this.view(id);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Fast-forward the base branch, checked out in the main folder, to the team's branch. */
|
|
891
|
+
async merge(owner) {
|
|
892
|
+
if (!owner?.sourceDir || !owner.branch) throw err(409, "The team's branch isn't in a worktree of a local repository; merge it yourself");
|
|
893
|
+
const top = owner.sourceDir;
|
|
894
|
+
const current = await git.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: top });
|
|
895
|
+
const dirty = await git.run('git', ['status', '--porcelain'], { cwd: top });
|
|
896
|
+
if (dirty) throw err(409, `${top} has uncommitted changes; commit or stash them, then merge`);
|
|
897
|
+
try {
|
|
898
|
+
await git.run('git', ['merge', '--ff-only', owner.branch], { cwd: top });
|
|
899
|
+
} catch {
|
|
900
|
+
throw err(409, `${current} in ${top} has moved since the team branched; merge ${owner.branch} yourself, or send the work back`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Stop the team: its agents close (they can be resumed); nothing is deleted. */
|
|
905
|
+
async stop(id) {
|
|
906
|
+
const team = this.get(id);
|
|
907
|
+
Object.assign(team, { stage: 'stopped', waiting: [], blocked: null, finishedAt: now() });
|
|
908
|
+
this.save(team);
|
|
909
|
+
for (const m of this.memberList(team)) if (this.manager.store.get(m.id)) await this.manager.stop(m.id).catch(() => {});
|
|
910
|
+
return this.view(id);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Forget the team; with `agents`, delete its agents and the worktree too. */
|
|
914
|
+
async remove(id, { agents = false } = {}) {
|
|
915
|
+
const team = this.get(id);
|
|
916
|
+
delete this.store.data.teams[id];
|
|
917
|
+
this.save();
|
|
918
|
+
this.emit('removed', id);
|
|
919
|
+
if (agents) {
|
|
920
|
+
// The owner last, so the shared worktree goes when nobody else is in it.
|
|
921
|
+
const ownerId = team.members.implementer ?? team.members.lead;
|
|
922
|
+
for (const m of this.memberList(team).filter((x) => x.id !== ownerId)) if (this.manager.store.get(m.id)) await this.manager.remove(m.id).catch(() => {});
|
|
923
|
+
if (this.manager.store.get(ownerId)) await this.manager.remove(ownerId, { deleteWorkspace: true }).catch(() => {});
|
|
924
|
+
fs.rmSync(team.dir, { recursive: true, force: true });
|
|
925
|
+
}
|
|
926
|
+
return { ok: true };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** One handoff file's text. */
|
|
930
|
+
file(id, name) {
|
|
931
|
+
const team = this.get(id);
|
|
932
|
+
if (!/^(quest-\d+\/)?[\w-][\w.-]*\.md$/.test(name)) throw err(400, 'Not a team file');
|
|
933
|
+
try {
|
|
934
|
+
return { name, markdown: fs.readFileSync(path.join(team.dir, name), 'utf8') };
|
|
935
|
+
} catch {
|
|
936
|
+
throw err(404, `No file ${name} yet`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
view(id) {
|
|
941
|
+
const team = this.get(id);
|
|
942
|
+
const members = this.memberList(team).map((m) => {
|
|
943
|
+
const a = this.manager.store.get(m.id);
|
|
944
|
+
const rt = this.manager.runtimes.get(m.id);
|
|
945
|
+
const costs = (a?.sessions ?? []).map((s) => s.cost?.usd).filter((c) => c != null);
|
|
946
|
+
return {
|
|
947
|
+
...m,
|
|
948
|
+
status: a?.status ?? 'deleted',
|
|
949
|
+
working: Boolean(rt?.pty && rt.busy),
|
|
950
|
+
model: a?.model ?? null,
|
|
951
|
+
engine: a?.engine ?? null,
|
|
952
|
+
costUsd: costs.length ? costs.reduce((x, y) => x + y, 0) : null,
|
|
953
|
+
turn: team.mode === 'lead' ? ACTIVE.has(team.stage) : team.waiting.some((w) => w.member === m.id && !w.verdict),
|
|
954
|
+
};
|
|
955
|
+
});
|
|
956
|
+
let files = [];
|
|
957
|
+
try {
|
|
958
|
+
files = fs.readdirSync(this.questPath(team, '')).filter((f) => f.endsWith('.md')).sort();
|
|
959
|
+
} catch {}
|
|
960
|
+
const costs = members.map((m) => m.costUsd).filter((c) => c != null);
|
|
961
|
+
const owner = this.manager.store.get(team.members.implementer ?? team.members.lead);
|
|
962
|
+
return {
|
|
963
|
+
...team,
|
|
964
|
+
mode: team.mode ?? 'agents',
|
|
965
|
+
members,
|
|
966
|
+
// A lead's roles, played by its sub-agents.
|
|
967
|
+
subagents: team.mode === 'lead' ? SUB_ROLES.filter((r) => r !== 'critic' || team.settings.critics).map((role) => ({ role, model: team.settings.models?.[role] ?? null })) : [],
|
|
968
|
+
files,
|
|
969
|
+
// What the latest review says is still missing: the person can run a gap round for it.
|
|
970
|
+
gaps: team.history.findLast((h) => h.role === 'reviewer')?.coverage === 'partial',
|
|
971
|
+
gapList: (() => {
|
|
972
|
+
const g = this.lastGaps(team);
|
|
973
|
+
const text = g ? gapsOf(g.text) : '';
|
|
974
|
+
return text.length > 2000 ? `${text.slice(0, 1999)}…` : text || null;
|
|
975
|
+
})(),
|
|
976
|
+
branch: owner?.branch ?? null,
|
|
977
|
+
folder: owner?.sourceDir ?? owner?.workdir ?? null,
|
|
978
|
+
// No GitHub (or other) remote: publish, merge locally or keep, instead of a pull request.
|
|
979
|
+
remote: Boolean(owner?.git?.remote),
|
|
980
|
+
prs: Object.values(owner?.prs ?? {}).map((p) => ({ url: p.url, number: p.number, state: p.state })),
|
|
981
|
+
costUsd: costs.length ? costs.reduce((x, y) => x + y, 0) : null,
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
}
|