@chatpanel/gateway 0.6.87 → 0.6.90
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/package.json +1 -1
- package/src/budget.js +117 -0
- package/src/engine-ledger-store.js +150 -0
- package/src/engine.js +132 -0
- package/src/gate.js +75 -0
- package/src/job.js +149 -0
- package/src/model-ledger.js +205 -0
- package/src/project-store.js +139 -0
- package/src/project.js +171 -0
- package/src/scorecard-store.js +1 -1
- package/src/scorecard.js +148 -4
- package/src/server.js +123 -9
- package/src/team-store.js +4 -1
- package/src/team.js +303 -0
package/src/team.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/team.js — edit there, then copy over.
|
|
2
|
+
// A team, as data — roles with grants, a merge policy, a budget. Nothing runs here.
|
|
3
|
+
//
|
|
4
|
+
// The Notes co-writer swarm was one team, hard-wired: a planner, four roles appointed per
|
|
5
|
+
// model, a shared board. The desktop was about to copy it, and every client would then hold
|
|
6
|
+
// its own answer to "what is a researcher allowed to touch". So a team is declared once, in
|
|
7
|
+
// this shape, and shared through the client-prefs document like a skill or a recipe: defined
|
|
8
|
+
// in one client, invokable in the other at its next open.
|
|
9
|
+
//
|
|
10
|
+
// Two invariants are enforced here rather than trusted:
|
|
11
|
+
// • A role's GRANTS name tool groups, never tools — and `page` is not grantable. A tab is
|
|
12
|
+
// one person's; a team member acting on it is the one thing every guard was written to
|
|
13
|
+
// stop. `none` is a legitimate grant: a writer needs no tools.
|
|
14
|
+
// • A team has a BUDGET, or it is not a team (F8 O1). `validateTeam` refuses one without.
|
|
15
|
+
//
|
|
16
|
+
// Trust is derived, never declared (the skill-manifest rule): a stored `builtin` cannot
|
|
17
|
+
// survive an `origin`, and a team a client stores as trusted is stored as nothing of the kind.
|
|
18
|
+
|
|
19
|
+
import { validateBudget, normalizeBudget } from './budget.js';
|
|
20
|
+
import { normalizeEngineSpec, validateEngineSpec, tierOf } from './engine.js';
|
|
21
|
+
|
|
22
|
+
export const TEAM_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
23
|
+
export const ROLE_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/i;
|
|
24
|
+
export const ROLE_MODES = Object.freeze(['model', 'subagent', 'recipe']);
|
|
25
|
+
export const ROLE_PREFERS = Object.freeze(['cheap', 'balanced', 'strong']);
|
|
26
|
+
export const MERGE_POLICIES = Object.freeze(['judge', 'converge', 'concat', 'first']);
|
|
27
|
+
export const PLAN_MODES = Object.freeze(['fixed', 'planner']);
|
|
28
|
+
/**
|
|
29
|
+
* The tool groups a role may hold. `mcp:<server>` narrows to one server; `mcp` is all of them.
|
|
30
|
+
*
|
|
31
|
+
* The work grants (architecture-pillars.md §14.2) are for an agent whose engine is a harness
|
|
32
|
+
* running in a checkout: `shell` and `fs:write` say so explicitly instead of riding along
|
|
33
|
+
* with the harness; `scm:read` reads the repo and its hub, `scm:push` pushes ITS OWN branch
|
|
34
|
+
* (`cp/<project>/<job>`), `scm:pr` opens a pull request, and `scm:merge` is held by the
|
|
35
|
+
* Gate — grantable only where the org's `gate.json` allows it. A chat-model role that holds
|
|
36
|
+
* one of these holds nothing: only a harness engine can use them, and the bridge enforces it.
|
|
37
|
+
*/
|
|
38
|
+
export const GRANTABLE = Object.freeze(['none', 'data', 'web', 'mcp', 'history', 'shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr', 'scm:merge']);
|
|
39
|
+
export const WORK_GRANTS = Object.freeze(['shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr', 'scm:merge']);
|
|
40
|
+
export const GRANT_RE = /^(none|data|web|history|mcp|mcp:[a-zA-Z0-9_.:-]{1,64}|shell|fs:write|scm:(read|push|pr|merge))$/;
|
|
41
|
+
export const MAX_ROLES = 8;
|
|
42
|
+
/** A role that stands for an agent from the pool: `agent` names it (agent.js `AGENT_ID_RE`). */
|
|
43
|
+
export const AGENT_REF_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
44
|
+
|
|
45
|
+
export class TeamError extends Error {
|
|
46
|
+
constructor(code, message) { super(message); this.name = 'TeamError'; this.code = code; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
50
|
+
|
|
51
|
+
/** A role's grants, normalized: `none` alone means no tools; duplicates and `page` are dropped. */
|
|
52
|
+
export function normalizeGrants(grants) {
|
|
53
|
+
const list = (Array.isArray(grants) ? grants : typeof grants === 'string' ? [grants] : []).map((g) => String(g || '').trim()).filter(Boolean);
|
|
54
|
+
const ok = [...new Set(list.filter((g) => GRANT_RE.test(g)))];
|
|
55
|
+
if (!ok.length || ok.includes('none')) return ['none'];
|
|
56
|
+
return ok;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validateTeam(team) {
|
|
60
|
+
const errors = [];
|
|
61
|
+
if (!isRecord(team)) return { ok: false, errors: ['team must be an object'] };
|
|
62
|
+
if (!TEAM_NAME_RE.test(String(team.name || ''))) errors.push('name: a short identifier (letters, digits, _ -)');
|
|
63
|
+
if (!Array.isArray(team.roles) || !team.roles.length) errors.push('roles: a non-empty array');
|
|
64
|
+
else {
|
|
65
|
+
if (team.roles.length > MAX_ROLES) errors.push(`roles: at most ${MAX_ROLES}`);
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
team.roles.forEach((r, i) => {
|
|
68
|
+
const w = `roles[${i}]`;
|
|
69
|
+
if (!isRecord(r)) { errors.push(`${w}: must be an object`); return; }
|
|
70
|
+
if (!ROLE_ID_RE.test(String(r.id || ''))) errors.push(`${w}.id: a short identifier`);
|
|
71
|
+
else if (seen.has(r.id)) errors.push(`${w}.id: duplicate "${r.id}"`);
|
|
72
|
+
seen.add(r.id);
|
|
73
|
+
if (r.mode !== undefined && !ROLE_MODES.includes(r.mode)) errors.push(`${w}.mode: one of ${ROLE_MODES.join(', ')}`);
|
|
74
|
+
if (r.prefer !== undefined && !ROLE_PREFERS.includes(r.prefer)) errors.push(`${w}.prefer: one of ${ROLE_PREFERS.join(', ')}`);
|
|
75
|
+
if ((r.mode || 'model') === 'recipe' && !r.recipe) errors.push(`${w}.recipe: a recipe name is required in recipe mode`);
|
|
76
|
+
if (r.agent !== undefined && r.agent !== null && !AGENT_REF_RE.test(String(r.agent))) errors.push(`${w}.agent: an agent id`);
|
|
77
|
+
// A role that stands for an agent takes its prompt from the pool (agent.js resolveTeam);
|
|
78
|
+
// a role that stands for nobody must say what it does.
|
|
79
|
+
if ((r.mode || 'model') !== 'recipe' && !r.agent && !String(r.prompt || '').trim()) errors.push(`${w}.prompt: what this role does`);
|
|
80
|
+
errors.push(...validateEngineSpec(r.engine, `${w}.engine`));
|
|
81
|
+
const bad = (Array.isArray(r.grants) ? r.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
82
|
+
if (bad.length) errors.push(`${w}.grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; a team may not act on it)' : ''}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (team.merge !== undefined && !MERGE_POLICIES.includes(team.merge)) errors.push(`merge: one of ${MERGE_POLICIES.join(', ')}`);
|
|
86
|
+
if (team.plan !== undefined && !PLAN_MODES.includes(team.plan)) errors.push(`plan: one of ${PLAN_MODES.join(', ')}`);
|
|
87
|
+
if (team.judge !== undefined && team.judge !== null && !(Array.isArray(team.roles) && team.roles.some((r) => r?.id === team.judge))) errors.push('judge: must name one of the roles');
|
|
88
|
+
const b = validateBudget(team.budget);
|
|
89
|
+
if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`));
|
|
90
|
+
return { ok: errors.length === 0, errors };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The stored form. Defaults filled, grants normalized, trust derived: `builtin` only when the
|
|
95
|
+
* host says so, never from the record.
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeTeam(team, { builtin = false } = {}) {
|
|
98
|
+
const v = validateTeam(team);
|
|
99
|
+
if (!v.ok) throw new TeamError('INVALID', v.errors.join('; '));
|
|
100
|
+
return {
|
|
101
|
+
name: String(team.name),
|
|
102
|
+
description: String(team.description || '').trim().slice(0, 300),
|
|
103
|
+
plan: PLAN_MODES.includes(team.plan) ? team.plan : 'fixed',
|
|
104
|
+
merge: MERGE_POLICIES.includes(team.merge) ? team.merge : (team.judge ? 'judge' : 'concat'),
|
|
105
|
+
judge: team.judge || null,
|
|
106
|
+
roles: team.roles.map((r) => ({
|
|
107
|
+
id: String(r.id),
|
|
108
|
+
name: String(r.name || r.id).slice(0, 60),
|
|
109
|
+
mode: ROLE_MODES.includes(r.mode) ? r.mode : 'model',
|
|
110
|
+
prefer: ROLE_PREFERS.includes(r.prefer) ? r.prefer : (r.engine ? tierOf(r.engine) : 'balanced'),
|
|
111
|
+
...(r.model ? { model: String(r.model) } : {}),
|
|
112
|
+
...(r.agent ? { agent: String(r.agent) } : {}),
|
|
113
|
+
...(r.engine ? { engine: normalizeEngineSpec(r.engine) } : {}),
|
|
114
|
+
prompt: String(r.prompt || '').trim().slice(0, 4000),
|
|
115
|
+
// A role that stands for an agent holds the agent's grants unless it narrows them: no
|
|
116
|
+
// list means "the agent's", so the key is left out rather than stored as `none`.
|
|
117
|
+
...(r.agent && !(Array.isArray(r.grants) && r.grants.length) ? {} : { grants: normalizeGrants(r.grants) }),
|
|
118
|
+
...(r.recipe ? { recipe: String(r.recipe) } : {}),
|
|
119
|
+
...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String).filter((d) => d !== r.id) } : {}),
|
|
120
|
+
// What agent.js resolveTeam fills from the pool; kept so the runner's roles carry it.
|
|
121
|
+
...(Array.isArray(r.skills) && r.skills.length ? { skills: r.skills.map(String).slice(0, 32) } : {}),
|
|
122
|
+
...(r.workdir ? { workdir: String(r.workdir).slice(0, 400) } : {}),
|
|
123
|
+
...(r.egress === 'redacted' || r.egress === 'delegated' ? { egress: r.egress } : {}),
|
|
124
|
+
...(r.memoryScope ? { memoryScope: String(r.memoryScope).slice(0, 120) } : {}),
|
|
125
|
+
})),
|
|
126
|
+
budget: normalizeBudget(team.budget),
|
|
127
|
+
enabled: team.enabled !== false,
|
|
128
|
+
...(team.origin && isRecord(team.origin) ? { origin: { ...team.origin } } : {}),
|
|
129
|
+
...(builtin ? { builtin: true } : {}),
|
|
130
|
+
...(team.createdAt ? { createdAt: team.createdAt } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function defineTeam(team) { return Object.freeze(normalizeTeam(team)); }
|
|
135
|
+
|
|
136
|
+
/** Which of a client's tool groups a role may hold — `(groupId, serverId?) => boolean`. */
|
|
137
|
+
export function grantAllows(grants, groupId, serverId = '') {
|
|
138
|
+
const g = normalizeGrants(grants);
|
|
139
|
+
if (g.includes('none')) return false;
|
|
140
|
+
if (groupId === 'page') return false;
|
|
141
|
+
if (groupId === 'mcp') return g.includes('mcp') || (!!serverId && g.includes(`mcp:${serverId}`));
|
|
142
|
+
return g.includes(groupId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The SCM ladder: `merge` ⊃ `pr` ⊃ `push` ⊃ `read` — a role that may open a PR may push the
|
|
147
|
+
* branch the PR is from, and anyone who may push may read. `push` is the role's OWN branch
|
|
148
|
+
* only; the bridge names it (`cp/<project>/<job>`) and refuses any other.
|
|
149
|
+
*/
|
|
150
|
+
const SCM_LADDER = ['read', 'push', 'pr', 'merge'];
|
|
151
|
+
export function scmAllows(grants, action) {
|
|
152
|
+
const g = normalizeGrants(grants);
|
|
153
|
+
const want = SCM_LADDER.indexOf(String(action || '').replace(/^scm:/, ''));
|
|
154
|
+
if (want < 0 || g.includes('none')) return false;
|
|
155
|
+
const held = Math.max(-1, ...g.filter((x) => x.startsWith('scm:')).map((x) => SCM_LADDER.indexOf(x.slice(4))));
|
|
156
|
+
return held >= want;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One line a person reads per role: name · tier/model · grants · mode. */
|
|
160
|
+
export function describeRole(r) {
|
|
161
|
+
const who = r.model || r.prefer || 'balanced';
|
|
162
|
+
const grants = (r.grants || ['none']).join(', ');
|
|
163
|
+
return `${r.name || r.id}${r.agent ? ` (agent: ${r.agent})` : ''} — ${who}${r.mode && r.mode !== 'model' ? ` (${r.mode})` : ''} · tools: ${grants}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Starters and the editor's form ───────────────────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// A team is usually proposed in conversation, but a person's first team should not depend
|
|
169
|
+
// on a model deciding to propose one. Both clients' Settings → Teams offer these as "Add
|
|
170
|
+
// starter" and edit them on the same form as a blank team; `teamFromForm` is the one shaping
|
|
171
|
+
// of that form into a team, so a field's meaning does not differ between clients.
|
|
172
|
+
|
|
173
|
+
export const STARTER_TEAMS = Object.freeze([
|
|
174
|
+
{
|
|
175
|
+
name: 'research',
|
|
176
|
+
description: 'Research a question from the web and your own notes, then write it up.',
|
|
177
|
+
plan: 'fixed', merge: 'judge', judge: 'writer',
|
|
178
|
+
roles: [
|
|
179
|
+
{ id: 'researcher', prompt: 'Research the request thoroughly. Search the web and the user\'s own history. Report each fact as a finding with where it came from; note disagreements between sources.', prefer: 'balanced', grants: ['web', 'data'] },
|
|
180
|
+
{ id: 'writer', prompt: 'Write the answer the user asked for from the board\'s findings, citing them. Say plainly what was not found.', prefer: 'strong', grants: ['none'] },
|
|
181
|
+
],
|
|
182
|
+
budget: { tokens: 40000, ms: 300000 },
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'review',
|
|
186
|
+
description: 'Two independent reads of a draft, reconciled into one set of comments.',
|
|
187
|
+
plan: 'fixed', merge: 'converge',
|
|
188
|
+
roles: [
|
|
189
|
+
{ id: 'editor', prompt: 'Read the draft as an editor: structure, clarity, what is missing. One finding per issue, with the passage it refers to.', prefer: 'strong', grants: ['none'] },
|
|
190
|
+
{ id: 'checker', prompt: 'Read the draft as a fact-checker: every claim that could be wrong, with what you checked against. Use the user\'s history and the web.', prefer: 'balanced', grants: ['data', 'web'] },
|
|
191
|
+
],
|
|
192
|
+
budget: { tokens: 30000, ms: 240000 },
|
|
193
|
+
},
|
|
194
|
+
// ── The engineering teams (architecture-pillars.md §12.2) — roles stand for the standing
|
|
195
|
+
// agents in agent.js STARTER_AGENTS; a role's prompt is the agent's, its engine the agent's.
|
|
196
|
+
// A feature that crosses repos recruits one Implementer per repo (the role says which via
|
|
197
|
+
// its prompt); these starters name one.
|
|
198
|
+
{
|
|
199
|
+
name: 'feature',
|
|
200
|
+
description: 'Architect plans, an Implementer builds on a branch, Reviewer and Tester check, Scribe writes it up. The Architect judges.',
|
|
201
|
+
plan: 'planner', merge: 'judge', judge: 'architect',
|
|
202
|
+
roles: [
|
|
203
|
+
{ id: 'architect', agent: 'architect' },
|
|
204
|
+
{ id: 'implementer', agent: 'implementer', dependsOn: ['architect'] },
|
|
205
|
+
{ id: 'reviewer', agent: 'reviewer', dependsOn: ['implementer'] },
|
|
206
|
+
{ id: 'tester', agent: 'tester', dependsOn: ['implementer'] },
|
|
207
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['reviewer', 'tester'] },
|
|
208
|
+
],
|
|
209
|
+
budget: { tokens: 400000, ms: 3600000 },
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'fix',
|
|
213
|
+
description: 'One Implementer fixes it on a branch, the Tester runs the guard, the Scribe notes it.',
|
|
214
|
+
plan: 'fixed', merge: 'concat',
|
|
215
|
+
roles: [
|
|
216
|
+
{ id: 'implementer', agent: 'implementer' },
|
|
217
|
+
{ id: 'tester', agent: 'tester', dependsOn: ['implementer'] },
|
|
218
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['tester'] },
|
|
219
|
+
],
|
|
220
|
+
budget: { tokens: 150000, ms: 1800000 },
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: 'docs',
|
|
224
|
+
description: 'The Architect decides what the docs should say; the Scribe proposes the text.',
|
|
225
|
+
plan: 'fixed', merge: 'concat',
|
|
226
|
+
roles: [
|
|
227
|
+
{ id: 'architect', agent: 'architect' },
|
|
228
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['architect'] },
|
|
229
|
+
],
|
|
230
|
+
budget: { tokens: 80000, ms: 900000 },
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'release',
|
|
234
|
+
description: 'The Tester runs the guard on the merged branch, Release bumps and asks before publishing, the Scribe records the version.',
|
|
235
|
+
plan: 'fixed', merge: 'concat',
|
|
236
|
+
roles: [
|
|
237
|
+
{ id: 'tester', agent: 'tester' },
|
|
238
|
+
{ id: 'release', agent: 'release', dependsOn: ['tester'] },
|
|
239
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['release'] },
|
|
240
|
+
],
|
|
241
|
+
budget: { tokens: 60000, ms: 1800000 },
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A name as typed → the identifier a `/command` needs: "Research Team" → "research-team".
|
|
247
|
+
* Used by the tool's save and by the form, so a model that names a team in prose is not
|
|
248
|
+
* bounced for it; what cannot be shaped (nothing left) still fails validation.
|
|
249
|
+
*/
|
|
250
|
+
export function slugTeamName(name) {
|
|
251
|
+
return String(name || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Fresh copies — a starter is a template, never the stored record. */
|
|
255
|
+
export function starterTeams() {
|
|
256
|
+
return STARTER_TEAMS.map((t) => ({ ...t, roles: t.roles.map((r) => ({ ...r, ...(r.grants ? { grants: [...r.grants] } : {}), ...(r.dependsOn ? { dependsOn: [...r.dependsOn] } : {}) })), budget: { ...t.budget } }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A blank team for the editor: one role, the smallest budget that is still a budget. */
|
|
260
|
+
export function blankTeam() {
|
|
261
|
+
return { name: '', description: '', plan: 'fixed', merge: 'concat', judge: null, roles: [{ id: 'role1', prompt: '', prefer: 'balanced', grants: ['none'] }], budget: { tokens: 20000, ms: 300000 } };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The editor's form → a team, or the errors. Grants come as text ("web, data, mcp:srv"),
|
|
266
|
+
* the budget as numbers that may be blank; a blank judge under `merge: judge` is the last
|
|
267
|
+
* role, which is the writer in every starter.
|
|
268
|
+
*/
|
|
269
|
+
const grantsText = (g) => String(Array.isArray(g) ? g.join(',') : g || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
|
|
270
|
+
export function teamFromForm(form) {
|
|
271
|
+
const roles = (Array.isArray(form.roles) ? form.roles : []).map((r) => ({
|
|
272
|
+
id: String(r.id || '').trim(),
|
|
273
|
+
name: String(r.name || '').trim() || undefined,
|
|
274
|
+
prompt: String(r.prompt || ''),
|
|
275
|
+
prefer: r.prefer || 'balanced',
|
|
276
|
+
...(r.model ? { model: String(r.model) } : {}),
|
|
277
|
+
...(r.agent ? { agent: String(r.agent).trim() } : {}),
|
|
278
|
+
...(r.engine ? { engine: r.engine } : {}),
|
|
279
|
+
...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String) } : {}),
|
|
280
|
+
// Blank grants on a role that stands for an agent mean "the agent's"; on any other role
|
|
281
|
+
// they mean none.
|
|
282
|
+
...(grantsText(r.grants).length ? { grants: grantsText(r.grants) } : r.agent ? {} : { grants: ['none'] }),
|
|
283
|
+
}));
|
|
284
|
+
const budget = {};
|
|
285
|
+
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
|
286
|
+
const v = Number(form.budget?.[k]);
|
|
287
|
+
if (form.budget?.[k] !== '' && form.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
|
|
288
|
+
}
|
|
289
|
+
const merge = form.merge || 'concat';
|
|
290
|
+
const team = {
|
|
291
|
+
name: slugTeamName(form.name),
|
|
292
|
+
description: String(form.description || '').trim(),
|
|
293
|
+
plan: form.plan || 'fixed',
|
|
294
|
+
merge,
|
|
295
|
+
judge: merge === 'judge' ? (form.judge || roles[roles.length - 1]?.id || null) : null,
|
|
296
|
+
roles,
|
|
297
|
+
budget,
|
|
298
|
+
enabled: form.enabled !== false,
|
|
299
|
+
...(form.createdAt ? { createdAt: form.createdAt } : {}),
|
|
300
|
+
};
|
|
301
|
+
const v = validateTeam(team);
|
|
302
|
+
return v.ok ? { ok: true, team: normalizeTeam(team) } : { ok: false, errors: v.errors };
|
|
303
|
+
}
|