@nanmicoder/dsh-agent-teams 0.1.13 → 0.1.14
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/README.md +41 -5
- package/README_ZH.md +18 -5
- package/lib/client/ActivityPanel.js +219 -50
- package/lib/client/StagingPlanEditor.js +493 -0
- package/lib/client/activity-model.js +71 -0
- package/lib/client/activity-monitor.js +1 -0
- package/lib/client/index.js +2 -2
- package/lib/client/locales.js +224 -2
- package/lib/client.js +1775 -241
- package/lib/client.js.map +1 -1
- package/lib/command.js +116 -99
- package/lib/index.js +285 -13
- package/lib/members.js +137 -16
- package/lib/profiles.js +572 -0
- package/lib/quality-gates.js +777 -0
- package/lib/scheduler.js +167 -8
- package/lib/snapshot.js +25 -1
- package/lib/state.js +116 -10
- package/lib/tools.js +1230 -38
- package/lib/types/client/ActivityPanel.d.ts +3 -1
- package/lib/types/client/StagingPlanEditor.d.ts +17 -0
- package/lib/types/client/activity-model.d.ts +67 -0
- package/lib/types/client/activity-monitor.d.ts +14 -1
- package/lib/types/client/locales.d.ts +222 -0
- package/lib/types/command.d.ts +11 -56
- package/lib/types/event-types.d.ts +35 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/members.d.ts +48 -3
- package/lib/types/profiles.d.ts +124 -0
- package/lib/types/quality-gates.d.ts +148 -0
- package/lib/types/scheduler.d.ts +44 -0
- package/lib/types/snapshot.d.ts +18 -1
- package/lib/types/state.d.ts +8 -3
- package/lib/types/tools.d.ts +73 -9
- package/lib/types/types.d.ts +118 -0
- package/lib/types.js +11 -0
- package/package.json +10 -4
- package/release-notes/v0.1.14.md +68 -0
package/lib/profiles.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named team-profile templates: config types, normalization, invocation
|
|
3
|
+
* parsing, and prompt rendering.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions only — no I/O, no spawn. Runtime create/spawn stays in
|
|
6
|
+
* `tools.ts`; this module only turns a config map + a profile name into a
|
|
7
|
+
* validated, topologically ordered template (or parses `--profile` flags).
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-agent-teams/profiles
|
|
10
|
+
*/
|
|
11
|
+
import { CAPTAIN_KEY, sanitizeKey } from "./state.js";
|
|
12
|
+
/** Hard cap on named profiles so the usage prompt cannot grow without bound. */
|
|
13
|
+
export const MAX_TEAM_PROFILES = 16;
|
|
14
|
+
/** Hard cap on seed tasks per profile. The software-delivery example has 13. */
|
|
15
|
+
export const MAX_PROFILE_TASKS = 32;
|
|
16
|
+
/** Protocol excerpt length in the usage / prompt listing. */
|
|
17
|
+
export const PROFILE_PROTOCOL_PROMPT_LIMIT = 240;
|
|
18
|
+
const PROFILE_KEYS = ['description', 'protocol', 'executionPrompt', 'fallback', 'members', 'tasks', 'taskPlanning', 'reviewPolicy'];
|
|
19
|
+
const REVIEW_POLICY_KEYS = ['requirementsMinRounds', 'requirementsMaxRounds', 'codeMaxRounds', 'maxRepairAttempts', 'requiredReviewers'];
|
|
20
|
+
const MEMBER_KEYS = ['name', 'role', 'provider', 'model', 'reasoning_effort', 'executionPrompt', 'fallback'];
|
|
21
|
+
const FALLBACK_KEYS = ['provider', 'model'];
|
|
22
|
+
const TASK_KEYS = ['id', 'subject', 'description', 'assignee', 'dependencies'];
|
|
23
|
+
/**
|
|
24
|
+
* Trim every profile key once, reject empty / colliding keys, and reject
|
|
25
|
+
* more than {@link MAX_TEAM_PROFILES} entries. Does not validate profile
|
|
26
|
+
* bodies — that belongs to {@link resolveTeamProfile}.
|
|
27
|
+
*/
|
|
28
|
+
export function listConfiguredProfiles(profiles) {
|
|
29
|
+
const record = asProfilesRecord(profiles);
|
|
30
|
+
const keys = Object.keys(record);
|
|
31
|
+
if (keys.length > MAX_TEAM_PROFILES) {
|
|
32
|
+
throw new Error(`too many AgentTeams profiles (${keys.length}); the limit is ${MAX_TEAM_PROFILES}`);
|
|
33
|
+
}
|
|
34
|
+
const seen = new Map();
|
|
35
|
+
const listed = [];
|
|
36
|
+
for (const rawKey of keys) {
|
|
37
|
+
const name = rawKey.trim();
|
|
38
|
+
if (name === '') {
|
|
39
|
+
throw new Error('configured AgentTeams profiles include an empty key');
|
|
40
|
+
}
|
|
41
|
+
const previous = seen.get(name);
|
|
42
|
+
if (previous !== undefined) {
|
|
43
|
+
throw new Error(`configured AgentTeams profiles have duplicate key "${name}"`);
|
|
44
|
+
}
|
|
45
|
+
seen.set(name, rawKey);
|
|
46
|
+
listed.push({ name, config: record[rawKey] });
|
|
47
|
+
}
|
|
48
|
+
return listed;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Render the usage-prompt listing. One line per profile: name, member count,
|
|
52
|
+
* task count, protocol excerpt (at most 240 characters). Returns `''` when
|
|
53
|
+
* nothing is configured so callers can omit the capability entirely.
|
|
54
|
+
*/
|
|
55
|
+
export function formatProfilesForPrompt(profiles) {
|
|
56
|
+
const listed = listConfiguredProfiles(profiles);
|
|
57
|
+
if (listed.length === 0)
|
|
58
|
+
return '';
|
|
59
|
+
const lines = [
|
|
60
|
+
'Configured team profiles (pass profile= to agent_teams_create):',
|
|
61
|
+
...listed.map((entry) => formatProfileListingLine(entry)),
|
|
62
|
+
];
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Walk `rawInput` from the front and eat standalone profile flags. Only
|
|
67
|
+
* `--profile <name>`, `--profile=<name>`, and `profile=<name>` count; the
|
|
68
|
+
* first ordinary token stops the scan so a mid-sentence `profile=` stays in
|
|
69
|
+
* the goal. A leading ordinary token is never treated as a profile name.
|
|
70
|
+
*
|
|
71
|
+
* `--profile "name"` strips one matching pair of quotes. Repeat flags and a
|
|
72
|
+
* `--profile` with no name throw.
|
|
73
|
+
*/
|
|
74
|
+
export function parseProfileInvocation(rawInput) {
|
|
75
|
+
const tokens = tokenize(rawInput);
|
|
76
|
+
let index = 0;
|
|
77
|
+
let profile;
|
|
78
|
+
while (index < tokens.length) {
|
|
79
|
+
const token = tokens[index];
|
|
80
|
+
if (token === undefined)
|
|
81
|
+
break;
|
|
82
|
+
const parsed = parseLeadingProfileFlag(token, tokens[index + 1]);
|
|
83
|
+
if (parsed === undefined)
|
|
84
|
+
break;
|
|
85
|
+
if (profile !== undefined) {
|
|
86
|
+
throw new Error('duplicate AgentTeams profile flag');
|
|
87
|
+
}
|
|
88
|
+
profile = parsed.name;
|
|
89
|
+
index += parsed.consumed;
|
|
90
|
+
}
|
|
91
|
+
const goal = tokens.slice(index).join(' ');
|
|
92
|
+
return profile === undefined ? { goal } : { goal, profile };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Normalize and pre-validate one named profile. Failures throw before any
|
|
96
|
+
* caller should create a directory or spawn members.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveTeamProfile(profiles, profileName, maxMembers) {
|
|
99
|
+
const listed = listConfiguredProfiles(profiles);
|
|
100
|
+
const name = profileName.trim();
|
|
101
|
+
if (name === '') {
|
|
102
|
+
throw new Error('AgentTeams profile name must be a non-empty string');
|
|
103
|
+
}
|
|
104
|
+
const match = listed.find((entry) => entry.name === name);
|
|
105
|
+
if (match === undefined) {
|
|
106
|
+
const available = listed.map((entry) => entry.name);
|
|
107
|
+
const shown = available.length === 0 ? '(none)' : available.join(', ');
|
|
108
|
+
throw new Error(`unknown AgentTeams profile "${name}" — configured profiles: ${shown}`);
|
|
109
|
+
}
|
|
110
|
+
return normalizeListedProfile(match, maxMembers);
|
|
111
|
+
}
|
|
112
|
+
function formatProfileListingLine(entry) {
|
|
113
|
+
const memberCount = Array.isArray(entry.config.members) ? entry.config.members.length : 0;
|
|
114
|
+
const planning = resolveProfileTaskPlanning(entry.config);
|
|
115
|
+
const graph = planning === 'captain'
|
|
116
|
+
? 'captain planning'
|
|
117
|
+
: countLabel(Array.isArray(entry.config.tasks) ? entry.config.tasks.length : 0, 'task');
|
|
118
|
+
const counts = `(${countLabel(memberCount, 'member')}, ${graph})`;
|
|
119
|
+
const summary = protocolSummary(entry.config.protocol);
|
|
120
|
+
return summary === undefined
|
|
121
|
+
? `- ${entry.name} ${counts}`
|
|
122
|
+
: `- ${entry.name} ${counts}: ${summary}`;
|
|
123
|
+
}
|
|
124
|
+
/** Public lookup used by activation text and status rendering. */
|
|
125
|
+
export function resolveProfileTaskPlanning(config) {
|
|
126
|
+
return config?.taskPlanning === 'captain' ? 'captain' : 'seed';
|
|
127
|
+
}
|
|
128
|
+
function countLabel(count, noun) {
|
|
129
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
130
|
+
}
|
|
131
|
+
function protocolSummary(protocol) {
|
|
132
|
+
if (typeof protocol !== 'string')
|
|
133
|
+
return undefined;
|
|
134
|
+
const collapsed = protocol.trim().replace(/\s+/gu, ' ');
|
|
135
|
+
if (collapsed === '')
|
|
136
|
+
return undefined;
|
|
137
|
+
return collapsed.length <= PROFILE_PROTOCOL_PROMPT_LIMIT
|
|
138
|
+
? collapsed
|
|
139
|
+
: collapsed.slice(0, PROFILE_PROTOCOL_PROMPT_LIMIT);
|
|
140
|
+
}
|
|
141
|
+
function tokenize(rawInput) {
|
|
142
|
+
const trimmed = rawInput.trim();
|
|
143
|
+
if (trimmed === '')
|
|
144
|
+
return [];
|
|
145
|
+
return trimmed.split(/\s+/u);
|
|
146
|
+
}
|
|
147
|
+
function parseLeadingProfileFlag(token, nextToken) {
|
|
148
|
+
if (token === '--profile') {
|
|
149
|
+
if (nextToken === undefined) {
|
|
150
|
+
throw new Error('--profile flag is missing a profile name');
|
|
151
|
+
}
|
|
152
|
+
return { name: readProfileToken(nextToken), consumed: 2 };
|
|
153
|
+
}
|
|
154
|
+
if (token.startsWith('--profile=')) {
|
|
155
|
+
return { name: readProfileToken(token.slice('--profile='.length)), consumed: 1 };
|
|
156
|
+
}
|
|
157
|
+
if (token.startsWith('profile=')) {
|
|
158
|
+
return { name: readProfileToken(token.slice('profile='.length)), consumed: 1 };
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
function readProfileToken(raw) {
|
|
163
|
+
const name = stripOneQuotePair(raw).trim();
|
|
164
|
+
if (name === '') {
|
|
165
|
+
throw new Error('--profile flag is missing a profile name');
|
|
166
|
+
}
|
|
167
|
+
return name;
|
|
168
|
+
}
|
|
169
|
+
/** Strip a single matching pair of `"` or `'` quotes; leave unmatched quotes alone. */
|
|
170
|
+
function stripOneQuotePair(value) {
|
|
171
|
+
if (value.length < 2)
|
|
172
|
+
return value;
|
|
173
|
+
const first = value.at(0);
|
|
174
|
+
const last = value.at(-1);
|
|
175
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
176
|
+
return value.slice(1, -1);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
function normalizeListedProfile(listed, maxMembers) {
|
|
181
|
+
const path = `profiles.${listed.name}`;
|
|
182
|
+
const raw = asRecord(listed.config, path);
|
|
183
|
+
assertAllowedKeys(raw, PROFILE_KEYS, path);
|
|
184
|
+
const description = optionalNonEmptyString(raw['description'], `${path}.description`);
|
|
185
|
+
const protocol = optionalNonEmptyString(raw['protocol'], `${path}.protocol`);
|
|
186
|
+
const executionPrompt = optionalNonEmptyString(raw['executionPrompt'], `${path}.executionPrompt`);
|
|
187
|
+
const fallback = normalizeFallback(raw['fallback'], `${path}.fallback`);
|
|
188
|
+
const taskPlanning = normalizeTaskPlanning(raw['taskPlanning'], `${path}.taskPlanning`);
|
|
189
|
+
const reviewPolicy = normalizeReviewPolicy(raw['reviewPolicy'], `${path}.reviewPolicy`);
|
|
190
|
+
const membersRaw = raw['members'];
|
|
191
|
+
if (!Array.isArray(membersRaw) || membersRaw.length === 0) {
|
|
192
|
+
throw new Error(`AgentTeams profile "${listed.name}" has no members`);
|
|
193
|
+
}
|
|
194
|
+
if (membersRaw.length > maxMembers) {
|
|
195
|
+
throw new Error(`profile "${listed.name}" has ${membersRaw.length} members but maxMembers is ${maxMembers}`);
|
|
196
|
+
}
|
|
197
|
+
const members = [];
|
|
198
|
+
const memberByName = new Map();
|
|
199
|
+
const memberByKey = new Map();
|
|
200
|
+
for (let index = 0; index < membersRaw.length; index += 1) {
|
|
201
|
+
const member = normalizeMember(membersRaw[index], `${path}.members[${index}]`, listed.name);
|
|
202
|
+
const key = sanitizeKey(member.name);
|
|
203
|
+
const colliding = memberByKey.get(key);
|
|
204
|
+
if (colliding !== undefined) {
|
|
205
|
+
throw new Error(`profile members "${colliding.name}" and "${member.name}" collapse to the same name`);
|
|
206
|
+
}
|
|
207
|
+
memberByName.set(member.name, member);
|
|
208
|
+
memberByKey.set(key, member);
|
|
209
|
+
members.push(member);
|
|
210
|
+
}
|
|
211
|
+
const tasksRaw = raw['tasks'];
|
|
212
|
+
if (tasksRaw === undefined) {
|
|
213
|
+
return omitUndefined({
|
|
214
|
+
name: listed.name,
|
|
215
|
+
description,
|
|
216
|
+
protocol,
|
|
217
|
+
executionPrompt,
|
|
218
|
+
fallback,
|
|
219
|
+
taskPlanning,
|
|
220
|
+
reviewPolicy,
|
|
221
|
+
members,
|
|
222
|
+
tasks: [],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (!Array.isArray(tasksRaw)) {
|
|
226
|
+
throw new Error(`profiles.${listed.name}.tasks must be an array`);
|
|
227
|
+
}
|
|
228
|
+
if (tasksRaw.length > MAX_PROFILE_TASKS) {
|
|
229
|
+
throw new Error(`profile "${listed.name}" has ${tasksRaw.length} tasks but the limit is ${MAX_PROFILE_TASKS}`);
|
|
230
|
+
}
|
|
231
|
+
const requireAssignee = tasksRaw.length > 0;
|
|
232
|
+
const draftTasks = [];
|
|
233
|
+
const taskById = new Map();
|
|
234
|
+
const taskByKey = new Map();
|
|
235
|
+
for (let index = 0; index < tasksRaw.length; index += 1) {
|
|
236
|
+
const task = normalizeTask(tasksRaw[index], `${path}.tasks[${index}]`, listed.name, index, memberByName, memberByKey, requireAssignee);
|
|
237
|
+
const collidingId = taskById.get(task.id);
|
|
238
|
+
if (collidingId !== undefined) {
|
|
239
|
+
throw new Error(`profile "${listed.name}" has duplicate task id "${task.id}"`);
|
|
240
|
+
}
|
|
241
|
+
const key = sanitizeKey(task.id);
|
|
242
|
+
const collidingKey = taskByKey.get(key);
|
|
243
|
+
if (collidingKey !== undefined) {
|
|
244
|
+
throw new Error(`profile tasks "${collidingKey.id}" and "${task.id}" collapse to the same id`);
|
|
245
|
+
}
|
|
246
|
+
taskById.set(task.id, task);
|
|
247
|
+
taskByKey.set(key, task);
|
|
248
|
+
draftTasks.push(task);
|
|
249
|
+
}
|
|
250
|
+
const tasks = topoSortTasks(draftTasks, listed.name);
|
|
251
|
+
return omitUndefined({
|
|
252
|
+
name: listed.name,
|
|
253
|
+
description,
|
|
254
|
+
protocol,
|
|
255
|
+
executionPrompt,
|
|
256
|
+
fallback,
|
|
257
|
+
taskPlanning,
|
|
258
|
+
reviewPolicy,
|
|
259
|
+
members,
|
|
260
|
+
tasks: taskPlanning === 'captain' ? [] : tasks,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function normalizeTaskPlanning(value, path) {
|
|
264
|
+
if (value === undefined)
|
|
265
|
+
return 'seed';
|
|
266
|
+
if (value === 'captain' || value === 'seed')
|
|
267
|
+
return value;
|
|
268
|
+
throw new Error(`${path} must be "captain" or "seed"`);
|
|
269
|
+
}
|
|
270
|
+
function normalizeReviewPolicy(value, path) {
|
|
271
|
+
if (value === undefined)
|
|
272
|
+
return undefined;
|
|
273
|
+
const raw = asRecord(value, path);
|
|
274
|
+
assertAllowedKeys(raw, REVIEW_POLICY_KEYS, path);
|
|
275
|
+
const requirementsMinRounds = optionalPositiveInt(raw['requirementsMinRounds'], `${path}.requirementsMinRounds`);
|
|
276
|
+
const requirementsMaxRounds = optionalPositiveInt(raw['requirementsMaxRounds'], `${path}.requirementsMaxRounds`);
|
|
277
|
+
const codeMaxRounds = optionalPositiveInt(raw['codeMaxRounds'], `${path}.codeMaxRounds`);
|
|
278
|
+
const maxRepairAttempts = optionalPositiveInt(raw['maxRepairAttempts'], `${path}.maxRepairAttempts`);
|
|
279
|
+
if (requirementsMinRounds !== undefined
|
|
280
|
+
&& requirementsMaxRounds !== undefined
|
|
281
|
+
&& requirementsMinRounds > requirementsMaxRounds) {
|
|
282
|
+
throw new Error(`${path}.requirementsMinRounds must be <= requirementsMaxRounds`);
|
|
283
|
+
}
|
|
284
|
+
let requiredReviewers;
|
|
285
|
+
if (raw['requiredReviewers'] !== undefined) {
|
|
286
|
+
if (!Array.isArray(raw['requiredReviewers'])) {
|
|
287
|
+
throw new Error(`${path}.requiredReviewers must be an array of strings`);
|
|
288
|
+
}
|
|
289
|
+
requiredReviewers = raw['requiredReviewers'].map((item, index) => {
|
|
290
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
291
|
+
throw new Error(`${path}.requiredReviewers[${index}] must be a non-empty string`);
|
|
292
|
+
}
|
|
293
|
+
return item.trim();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return omitUndefined({
|
|
297
|
+
requirementsMinRounds,
|
|
298
|
+
requirementsMaxRounds,
|
|
299
|
+
codeMaxRounds,
|
|
300
|
+
maxRepairAttempts,
|
|
301
|
+
requiredReviewers,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function optionalPositiveInt(value, path) {
|
|
305
|
+
if (value === undefined)
|
|
306
|
+
return undefined;
|
|
307
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
308
|
+
throw new Error(`${path} must be a positive integer`);
|
|
309
|
+
}
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
function normalizeMember(value, path, profileName) {
|
|
313
|
+
const raw = asRecord(value, path);
|
|
314
|
+
assertAllowedKeys(raw, MEMBER_KEYS, path);
|
|
315
|
+
const name = requiredNonEmptyString(raw['name'], `${path}.name`, `profile "${profileName}" has a member with an empty name`);
|
|
316
|
+
if (isCaptainName(name)) {
|
|
317
|
+
throw new Error(`member name "${name}" is reserved for the captain`);
|
|
318
|
+
}
|
|
319
|
+
const role = optionalNonEmptyString(raw['role'], `${path}.role`);
|
|
320
|
+
const provider = optionalNonEmptyString(raw['provider'], `${path}.provider`);
|
|
321
|
+
const model = optionalNonEmptyString(raw['model'], `${path}.model`);
|
|
322
|
+
const reasoningEffort = optionalNonEmptyString(raw['reasoning_effort'], `${path}.reasoning_effort`);
|
|
323
|
+
const executionPrompt = optionalNonEmptyString(raw['executionPrompt'], `${path}.executionPrompt`);
|
|
324
|
+
const fallback = normalizeFallback(raw['fallback'], `${path}.fallback`);
|
|
325
|
+
if (provider !== undefined && model === undefined) {
|
|
326
|
+
throw new Error(`profile member "${name}" sets provider without model`);
|
|
327
|
+
}
|
|
328
|
+
return omitUndefined({ name, role, provider, model, reasoningEffort, executionPrompt, fallback });
|
|
329
|
+
}
|
|
330
|
+
function normalizeFallback(value, path) {
|
|
331
|
+
if (value === undefined)
|
|
332
|
+
return undefined;
|
|
333
|
+
const raw = asRecord(value, path);
|
|
334
|
+
assertAllowedKeys(raw, FALLBACK_KEYS, path);
|
|
335
|
+
const provider = requiredNonEmptyString(raw['provider'], `${path}.provider`, `${path}.provider must not be empty`);
|
|
336
|
+
const model = requiredNonEmptyString(raw['model'], `${path}.model`, `${path}.model must not be empty`);
|
|
337
|
+
return { provider, model };
|
|
338
|
+
}
|
|
339
|
+
function normalizeTask(value, path, profileName, sourceIndex, memberByName, memberByKey, requireAssignee) {
|
|
340
|
+
const raw = asRecord(value, path);
|
|
341
|
+
assertAllowedKeys(raw, TASK_KEYS, path);
|
|
342
|
+
const id = requiredNonEmptyString(raw['id'], `${path}.id`, `profile "${profileName}" has a task with an empty id`);
|
|
343
|
+
const subject = requiredNonEmptyString(raw['subject'], `${path}.subject`, `profile task "${id}" is missing a subject`);
|
|
344
|
+
const description = optionalNonEmptyString(raw['description'], `${path}.description`);
|
|
345
|
+
const dependencies = normalizeDependencies(raw['dependencies'], `${path}.dependencies`, id);
|
|
346
|
+
const assignee = resolveTaskAssignee(raw['assignee'], `${path}.assignee`, id, requireAssignee, memberByName, memberByKey);
|
|
347
|
+
return omitUndefined({
|
|
348
|
+
id,
|
|
349
|
+
subject,
|
|
350
|
+
description,
|
|
351
|
+
assignee,
|
|
352
|
+
dependencies,
|
|
353
|
+
sourceIndex,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function normalizeDependencies(value, path, taskId) {
|
|
357
|
+
if (value === undefined)
|
|
358
|
+
return [];
|
|
359
|
+
if (!Array.isArray(value)) {
|
|
360
|
+
throw new Error(`${path} must be an array of task ids`);
|
|
361
|
+
}
|
|
362
|
+
const dependencies = [];
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
365
|
+
const item = value[index];
|
|
366
|
+
if (typeof item !== 'string') {
|
|
367
|
+
throw new Error(`${path}[${index}] must be a string`);
|
|
368
|
+
}
|
|
369
|
+
const dependency = item.trim();
|
|
370
|
+
if (dependency === '') {
|
|
371
|
+
throw new Error(`${path}[${index}] must not be empty`);
|
|
372
|
+
}
|
|
373
|
+
if (dependency === taskId) {
|
|
374
|
+
throw new Error(`profile task "${taskId}" cannot depend on itself`);
|
|
375
|
+
}
|
|
376
|
+
if (seen.has(dependency))
|
|
377
|
+
continue;
|
|
378
|
+
seen.add(dependency);
|
|
379
|
+
dependencies.push(dependency);
|
|
380
|
+
}
|
|
381
|
+
return dependencies;
|
|
382
|
+
}
|
|
383
|
+
function resolveTaskAssignee(value, path, taskId, required, memberByName, memberByKey) {
|
|
384
|
+
if (value === undefined) {
|
|
385
|
+
if (required) {
|
|
386
|
+
throw new Error(`profile task "${taskId}" is missing an assignee`);
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
if (typeof value !== 'string') {
|
|
391
|
+
throw new Error(`${path} must be a string`);
|
|
392
|
+
}
|
|
393
|
+
const trimmed = value.trim();
|
|
394
|
+
if (trimmed === '') {
|
|
395
|
+
throw new Error(`profile task "${taskId}" is missing an assignee`);
|
|
396
|
+
}
|
|
397
|
+
if (isCaptainName(trimmed)) {
|
|
398
|
+
throw new Error(`profile task "${taskId}" cannot assign work to the captain`);
|
|
399
|
+
}
|
|
400
|
+
const exact = memberByName.get(trimmed);
|
|
401
|
+
if (exact !== undefined)
|
|
402
|
+
return exact.name;
|
|
403
|
+
const fuzzy = memberByKey.get(sanitizeKey(trimmed));
|
|
404
|
+
if (fuzzy !== undefined)
|
|
405
|
+
return fuzzy.name;
|
|
406
|
+
throw new Error(`profile task "${taskId}" assignee "${trimmed}" is not a profile member`);
|
|
407
|
+
}
|
|
408
|
+
function topoSortTasks(tasks, profileName) {
|
|
409
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
410
|
+
for (const task of tasks) {
|
|
411
|
+
for (const dependency of task.dependencies) {
|
|
412
|
+
if (!byId.has(dependency)) {
|
|
413
|
+
throw new Error(`profile task "${task.id}" depends on unknown task "${dependency}"`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const indegree = new Map();
|
|
418
|
+
const outgoing = new Map();
|
|
419
|
+
for (const task of tasks) {
|
|
420
|
+
indegree.set(task.id, 0);
|
|
421
|
+
outgoing.set(task.id, []);
|
|
422
|
+
}
|
|
423
|
+
for (const task of tasks) {
|
|
424
|
+
for (const dependency of task.dependencies) {
|
|
425
|
+
indegree.set(task.id, (indegree.get(task.id) ?? 0) + 1);
|
|
426
|
+
outgoing.get(dependency)?.push(task.id);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const ready = tasks
|
|
430
|
+
.filter((task) => (indegree.get(task.id) ?? 0) === 0)
|
|
431
|
+
.sort((left, right) => left.sourceIndex - right.sourceIndex);
|
|
432
|
+
const ordered = [];
|
|
433
|
+
while (ready.length > 0) {
|
|
434
|
+
const next = ready.shift();
|
|
435
|
+
if (next === undefined)
|
|
436
|
+
break;
|
|
437
|
+
ordered.push(next);
|
|
438
|
+
for (const childId of outgoing.get(next.id) ?? []) {
|
|
439
|
+
const remaining = (indegree.get(childId) ?? 0) - 1;
|
|
440
|
+
indegree.set(childId, remaining);
|
|
441
|
+
if (remaining === 0) {
|
|
442
|
+
const child = byId.get(childId);
|
|
443
|
+
if (child === undefined)
|
|
444
|
+
continue;
|
|
445
|
+
ready.push(child);
|
|
446
|
+
ready.sort((left, right) => left.sourceIndex - right.sourceIndex);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (ordered.length !== tasks.length) {
|
|
451
|
+
const cyclic = tasks
|
|
452
|
+
.filter((task) => !ordered.some((done) => done.id === task.id))
|
|
453
|
+
.map((task) => task.id);
|
|
454
|
+
throw new Error(formatCycleError(profileName, cyclic));
|
|
455
|
+
}
|
|
456
|
+
return ordered;
|
|
457
|
+
}
|
|
458
|
+
function formatCycleError(_profileName, cyclic) {
|
|
459
|
+
const first = cyclic[0] ?? 'unknown';
|
|
460
|
+
const second = cyclic[1];
|
|
461
|
+
if (cyclic.length === 1 || second === undefined) {
|
|
462
|
+
return `profile task "${first}" forms a dependency cycle`;
|
|
463
|
+
}
|
|
464
|
+
if (cyclic.length === 2) {
|
|
465
|
+
return `profile task "${first}" and "${second}" form a dependency cycle`;
|
|
466
|
+
}
|
|
467
|
+
const head = cyclic.slice(0, -1).map((id) => `"${id}"`).join(', ');
|
|
468
|
+
const tail = cyclic[cyclic.length - 1] ?? first;
|
|
469
|
+
return `profile tasks ${head}, and "${tail}" form a dependency cycle`;
|
|
470
|
+
}
|
|
471
|
+
function isCaptainName(name) {
|
|
472
|
+
return name.trim().toLowerCase() === CAPTAIN_KEY || sanitizeKey(name) === CAPTAIN_KEY;
|
|
473
|
+
}
|
|
474
|
+
function asProfilesRecord(profiles) {
|
|
475
|
+
if (profiles === undefined || profiles === null)
|
|
476
|
+
return {};
|
|
477
|
+
if (typeof profiles !== 'object' || Array.isArray(profiles)) {
|
|
478
|
+
throw new Error('AgentTeams profiles must be an object map of named templates');
|
|
479
|
+
}
|
|
480
|
+
return profiles;
|
|
481
|
+
}
|
|
482
|
+
function asRecord(value, path) {
|
|
483
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
484
|
+
throw new Error(`${path} must be an object`);
|
|
485
|
+
}
|
|
486
|
+
return value;
|
|
487
|
+
}
|
|
488
|
+
function assertAllowedKeys(value, allowed, path) {
|
|
489
|
+
const allow = new Set(allowed);
|
|
490
|
+
for (const key of Object.keys(value)) {
|
|
491
|
+
if (allow.has(key))
|
|
492
|
+
continue;
|
|
493
|
+
const suggestion = suggestField(key, allowed);
|
|
494
|
+
const hint = suggestion === undefined ? '' : `; did you mean ${suggestion}?`;
|
|
495
|
+
throw new Error(`${path}.${key} is unknown${hint}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function suggestField(unknown, allowed) {
|
|
499
|
+
const lower = unknown.toLowerCase();
|
|
500
|
+
const exact = allowed.find((candidate) => candidate.toLowerCase() === lower);
|
|
501
|
+
if (exact !== undefined)
|
|
502
|
+
return exact;
|
|
503
|
+
let best;
|
|
504
|
+
let bestDistance = Infinity;
|
|
505
|
+
for (const candidate of allowed) {
|
|
506
|
+
const distance = levenshtein(lower, candidate.toLowerCase());
|
|
507
|
+
if (distance < bestDistance) {
|
|
508
|
+
bestDistance = distance;
|
|
509
|
+
best = candidate;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (best !== undefined && bestDistance <= 2)
|
|
513
|
+
return best;
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
function levenshtein(left, right) {
|
|
517
|
+
const rows = left.length + 1;
|
|
518
|
+
const cols = right.length + 1;
|
|
519
|
+
const grid = [];
|
|
520
|
+
for (let row = 0; row < rows; row += 1) {
|
|
521
|
+
const line = [];
|
|
522
|
+
for (let col = 0; col < cols; col += 1) {
|
|
523
|
+
if (row === 0)
|
|
524
|
+
line.push(col);
|
|
525
|
+
else if (col === 0)
|
|
526
|
+
line.push(row);
|
|
527
|
+
else
|
|
528
|
+
line.push(0);
|
|
529
|
+
}
|
|
530
|
+
grid.push(line);
|
|
531
|
+
}
|
|
532
|
+
for (let row = 1; row < rows; row += 1) {
|
|
533
|
+
for (let col = 1; col < cols; col += 1) {
|
|
534
|
+
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
|
|
535
|
+
const del = (grid[row - 1]?.[col] ?? 0) + 1;
|
|
536
|
+
const ins = (grid[row]?.[col - 1] ?? 0) + 1;
|
|
537
|
+
const sub = (grid[row - 1]?.[col - 1] ?? 0) + cost;
|
|
538
|
+
const cell = grid[row];
|
|
539
|
+
if (cell !== undefined)
|
|
540
|
+
cell[col] = Math.min(del, ins, sub);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return grid[left.length]?.[right.length] ?? 0;
|
|
544
|
+
}
|
|
545
|
+
function requiredNonEmptyString(value, path, emptyMessage) {
|
|
546
|
+
if (value === undefined || typeof value !== 'string') {
|
|
547
|
+
throw new Error(`${path} must be a string`);
|
|
548
|
+
}
|
|
549
|
+
const trimmed = value.trim();
|
|
550
|
+
if (trimmed === '')
|
|
551
|
+
throw new Error(emptyMessage);
|
|
552
|
+
return trimmed;
|
|
553
|
+
}
|
|
554
|
+
function optionalNonEmptyString(value, path) {
|
|
555
|
+
if (value === undefined)
|
|
556
|
+
return undefined;
|
|
557
|
+
if (typeof value !== 'string') {
|
|
558
|
+
throw new Error(`${path} must be a string`);
|
|
559
|
+
}
|
|
560
|
+
const trimmed = value.trim();
|
|
561
|
+
if (trimmed === '') {
|
|
562
|
+
throw new Error(`${path} must not be empty`);
|
|
563
|
+
}
|
|
564
|
+
return trimmed;
|
|
565
|
+
}
|
|
566
|
+
function omitUndefined(value) {
|
|
567
|
+
for (const key of Object.keys(value)) {
|
|
568
|
+
if (value[key] === undefined)
|
|
569
|
+
delete value[key];
|
|
570
|
+
}
|
|
571
|
+
return value;
|
|
572
|
+
}
|