@chatpanel/events 0.85.0 → 0.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.js +249 -0
- package/attribution.js +132 -0
- package/client-prefs.js +10 -1
- package/engine.js +131 -0
- package/gate.js +74 -0
- package/index.js +13 -2
- package/job.js +149 -0
- package/model-candidates.js +358 -0
- package/model-ledger.js +228 -0
- package/model-picker.js +3 -1
- package/package.json +21 -1
- package/project.js +170 -0
- package/recruit.js +419 -0
- package/route-strategies.js +232 -0
- package/scm-connection.js +180 -0
- package/scorecard.js +148 -4
- package/team-run.js +41 -6
- package/team-tool.js +16 -6
- package/team-trail.js +6 -0
- package/team.js +104 -9
- package/voice-speaker.js +98 -0
package/agent.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// An AGENT, as data — the pool an org recruits from. Nothing runs here.
|
|
2
|
+
//
|
|
3
|
+
// A team's roles were inlined: each carried its own prompt, tier and grants, so the same
|
|
4
|
+
// "researcher" existed once per team and an edit landed in one of them. An agent is the role
|
|
5
|
+
// definition promoted out of the team (F8 §8 A1, architecture-pillars.md §9): a persistent
|
|
6
|
+
// identity — name, purpose, prompt, skills, grants, egress class, ENGINE, memory namespace —
|
|
7
|
+
// with a scorecard the runner writes and the store attests (scorecard.js). One agent stands
|
|
8
|
+
// in many teams; a team's role says `agent: <id>` and `resolveTeam` fills the role from the
|
|
9
|
+
// pool at run time, so the runner (team-run.js) is unchanged.
|
|
10
|
+
//
|
|
11
|
+
// The engine is a field on the card, never a nav item: `model` / `harness` / `auto` /
|
|
12
|
+
// `assistant` (engine.js). The built-in ASSISTANT is the agent behind every plain chat: its
|
|
13
|
+
// engine is whichever model the chat is on, which is why `engineOf` takes the chat's model.
|
|
14
|
+
//
|
|
15
|
+
// Trust is derived, never declared (the team.js rule): `builtin` only when the host says so.
|
|
16
|
+
// Grants are the team's vocabulary (team.js GRANT_RE), including the work grants a harness
|
|
17
|
+
// engine may hold; `page` is never grantable. The pool is shared through the client-prefs
|
|
18
|
+
// document (`agents` section) like teams, skills and recipes.
|
|
19
|
+
|
|
20
|
+
import { normalizeGrants, GRANT_RE, TeamError, normalizeTeam, slugTeamName } from './team.js';
|
|
21
|
+
import { normalizeEngineSpec, validateEngineSpec, describeEngine, engineRef, tierOf } from './engine.js';
|
|
22
|
+
|
|
23
|
+
export const AGENT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
24
|
+
export const APPLIES_TO = Object.freeze(['jobs', 'meetings', 'notes']);
|
|
25
|
+
export const EGRESS_CLASSES = Object.freeze(['redacted', 'delegated']);
|
|
26
|
+
export const MAX_SKILLS = 32;
|
|
27
|
+
export const ASSISTANT_ID = 'assistant';
|
|
28
|
+
|
|
29
|
+
export class AgentError extends Error {
|
|
30
|
+
constructor(code, message) { super(message); this.name = 'AgentError'; this.code = code; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => String(x || '').trim().slice(0, n)).filter(Boolean))].slice(0, max);
|
|
35
|
+
|
|
36
|
+
export function validateAgent(agent) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
if (!isRecord(agent)) return { ok: false, errors: ['agent must be an object'] };
|
|
39
|
+
if (!AGENT_ID_RE.test(String(agent.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
40
|
+
if (!String(agent.name || agent.id || '').trim()) errors.push('name: what to call it');
|
|
41
|
+
if (!String(agent.prompt || '').trim() && String(agent.id) !== ASSISTANT_ID) errors.push('prompt: what this agent does');
|
|
42
|
+
errors.push(...validateEngineSpec(agent.engine, 'engine'));
|
|
43
|
+
const bad = (Array.isArray(agent.grants) ? agent.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
44
|
+
if (bad.length) errors.push(`grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; an agent may not act on it)' : ''}`);
|
|
45
|
+
if (agent.egress !== undefined && agent.egress !== null && !EGRESS_CLASSES.includes(agent.egress)) errors.push(`egress: one of ${EGRESS_CLASSES.join(', ')}`);
|
|
46
|
+
if (agent.appliesTo !== undefined && (!Array.isArray(agent.appliesTo) || agent.appliesTo.some((a) => !APPLIES_TO.includes(a)))) errors.push(`appliesTo: a list of ${APPLIES_TO.join(', ')}`);
|
|
47
|
+
if (agent.skills !== undefined && !Array.isArray(agent.skills) && typeof agent.skills !== 'string') errors.push('skills: a list of skill names');
|
|
48
|
+
return { ok: errors.length === 0, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The stored form. Defaults filled, grants normalized, engine normalized, trust derived. The
|
|
53
|
+
* memory namespace defaults to the agent's own (`agent:<id>`); a team may point several
|
|
54
|
+
* agents at one shared namespace by naming it.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeAgent(agent, { builtin = false } = {}) {
|
|
57
|
+
const v = validateAgent(agent);
|
|
58
|
+
if (!v.ok) throw new AgentError('INVALID', v.errors.join('; '));
|
|
59
|
+
const id = String(agent.id);
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
name: String(agent.name || id).trim().slice(0, 60),
|
|
63
|
+
purpose: String(agent.purpose || '').trim().slice(0, 300),
|
|
64
|
+
prompt: String(agent.prompt || '').trim().slice(0, 8000),
|
|
65
|
+
skills: list(agent.skills, 80, MAX_SKILLS),
|
|
66
|
+
grants: normalizeGrants(agent.grants),
|
|
67
|
+
engine: normalizeEngineSpec(agent.engine),
|
|
68
|
+
...(EGRESS_CLASSES.includes(agent.egress) ? { egress: agent.egress } : {}),
|
|
69
|
+
appliesTo: Array.isArray(agent.appliesTo) && agent.appliesTo.length ? [...new Set(agent.appliesTo.filter((a) => APPLIES_TO.includes(a)))] : ['jobs'],
|
|
70
|
+
memoryScope: String(agent.memoryScope || '').trim().slice(0, 120) || `agent:${id}`,
|
|
71
|
+
...(agent.workdir ? { workdir: String(agent.workdir).trim().slice(0, 400) } : {}),
|
|
72
|
+
createdBy: String(agent.createdBy || 'person').slice(0, 80),
|
|
73
|
+
enabled: agent.enabled !== false,
|
|
74
|
+
...(agent.origin && isRecord(agent.origin) ? { origin: { ...agent.origin } } : {}),
|
|
75
|
+
...(builtin ? { builtin: true } : {}),
|
|
76
|
+
...(agent.createdAt ? { createdAt: agent.createdAt } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function defineAgent(agent, opts) { return Object.freeze(normalizeAgent(agent, opts)); }
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The built-in Assistant — the agent behind every plain chat. Its engine is the chat's model
|
|
84
|
+
* (`engineOf` resolves it); its grants are whatever the chat has; it applies everywhere.
|
|
85
|
+
*/
|
|
86
|
+
export function assistantAgent({ grants = ['data', 'web', 'history', 'mcp'] } = {}) {
|
|
87
|
+
return defineAgent({
|
|
88
|
+
id: ASSISTANT_ID, name: 'Assistant', purpose: 'The chat itself: answers, uses the tools you connected, remembers what you tell it.',
|
|
89
|
+
prompt: '', engine: 'assistant', grants, appliesTo: ['jobs', 'meetings', 'notes'], createdBy: 'chatpanel',
|
|
90
|
+
}, { builtin: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* WHAT RUNS this agent's turns, resolved: the Assistant's engine is the chat's model (the
|
|
95
|
+
* host passes `chatModel` as `{ providerId?, model }` or a string), which is why the
|
|
96
|
+
* Assistant has no engine of its own. Everything else returns its normalized spec. A legacy
|
|
97
|
+
* role (no `engine`) reads as `model` when it pins one, else `auto` at its tier.
|
|
98
|
+
*/
|
|
99
|
+
export function engineOf(agentOrRole, { chatModel = null } = {}) {
|
|
100
|
+
const a = agentOrRole || {};
|
|
101
|
+
if (a.engine !== undefined && a.engine !== null) {
|
|
102
|
+
const s = normalizeEngineSpec(a.engine);
|
|
103
|
+
if (s.kind !== 'assistant') return s;
|
|
104
|
+
if (chatModel) return normalizeEngineSpec(typeof chatModel === 'string' ? chatModel : { kind: 'model', providerId: chatModel.providerId, model: chatModel.model || chatModel.id });
|
|
105
|
+
return { kind: 'auto', policy: { prefer: 'balanced' } };
|
|
106
|
+
}
|
|
107
|
+
if (a.model) return normalizeEngineSpec({ kind: 'model', model: a.model });
|
|
108
|
+
const prefer = { cheap: 'cheapest-that-clears', strong: 'best-quality', balanced: 'balanced' }[a.prefer] || 'balanced';
|
|
109
|
+
return { kind: 'auto', policy: { prefer } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The capability strip's first column (§8): what kind of thing this agent is, in a word. */
|
|
113
|
+
export function describeAgent(agent, opts) {
|
|
114
|
+
const a = agent || {};
|
|
115
|
+
return `${a.name || a.id} — ${describeEngine(a.engine, opts)} · tools: ${(a.grants || ['none']).join(', ')}${a.skills?.length ? ` · skills: ${a.skills.join(', ')}` : ''}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function slugAgentId(name) { return slugTeamName(name); }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A team as the runner needs it: every role that says `agent` is filled from the pool —
|
|
122
|
+
* prompt, grants, skills, engine, working directory — and the role's own fields narrow it.
|
|
123
|
+
*
|
|
124
|
+
* • prompt the agent's, then the role's ("In this team: …") when the role adds one
|
|
125
|
+
* • grants the agent's, narrowed to the role's when the role lists any (a team may not
|
|
126
|
+
* widen an agent; it may lend less)
|
|
127
|
+
* • engine the role's override when it has one, else the agent's, with the Assistant
|
|
128
|
+
* resolved to `chatModel`; `prefer` follows for today's appointers
|
|
129
|
+
* • model what `callModel` is handed — `targetFor(engine)` when the host maps engines
|
|
130
|
+
* to its target ids (the extension's endpoint ids, the desktop's gateway ids),
|
|
131
|
+
* else the harness id or the model name
|
|
132
|
+
*
|
|
133
|
+
* A role naming an agent not in the pool throws NO_AGENT: a team is not run with a hole in
|
|
134
|
+
* it. Returns the normalized team plus `agents` — the resolved cards, by role id.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveTeam(team, pool = [], { chatModel = null, targetFor = null } = {}) {
|
|
137
|
+
const t = normalizeTeam(team);
|
|
138
|
+
const byId = new Map((Array.isArray(pool) ? pool : []).filter((a) => a && a.id).map((a) => [String(a.id), a]));
|
|
139
|
+
const agents = {};
|
|
140
|
+
const roles = t.roles.map((r) => {
|
|
141
|
+
if (!r.agent) return r;
|
|
142
|
+
const raw = byId.get(r.agent) || (r.agent === ASSISTANT_ID ? assistantAgent() : null);
|
|
143
|
+
if (!raw) throw new TeamError('NO_AGENT', `role "${r.id}" names agent "${r.agent}", which is not in the pool`);
|
|
144
|
+
const a = normalizeAgent(raw, { builtin: !!raw.builtin });
|
|
145
|
+
if (a.enabled === false) throw new TeamError('NO_AGENT', `agent "${a.id}" is disabled`);
|
|
146
|
+
agents[r.id] = a;
|
|
147
|
+
const engine = engineOf(r.engine ? { engine: r.engine } : a, { chatModel });
|
|
148
|
+
const ref = engineRef(engine);
|
|
149
|
+
const target = ref ? (targetFor ? targetFor(engine, a) : (engine.kind === 'harness' ? engine.harnessId : engine.model)) : null;
|
|
150
|
+
const grants = !Array.isArray(r.grants) ? a.grants
|
|
151
|
+
: a.grants.includes('none') ? ['none']
|
|
152
|
+
: normalizeGrants(r.grants.filter((g) => a.grants.includes(g) || (g.startsWith('mcp:') && a.grants.includes('mcp'))));
|
|
153
|
+
const prompt = [a.prompt, r.prompt ? `In this team: ${r.prompt}` : ''].filter(Boolean).join('\n\n').slice(0, 8000);
|
|
154
|
+
return {
|
|
155
|
+
...r,
|
|
156
|
+
name: r.name === r.id ? a.name : r.name,
|
|
157
|
+
prompt,
|
|
158
|
+
grants,
|
|
159
|
+
engine,
|
|
160
|
+
prefer: engine.kind === 'auto' ? tierOf(engine) : r.prefer,
|
|
161
|
+
...(target ? { model: r.model || target } : {}),
|
|
162
|
+
...(a.skills.length ? { skills: [...a.skills] } : {}),
|
|
163
|
+
...(a.workdir ? { workdir: a.workdir } : {}),
|
|
164
|
+
...(a.egress ? { egress: a.egress } : {}),
|
|
165
|
+
...(a.memoryScope ? { memoryScope: a.memoryScope } : {}),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
return { ...t, roles, agents };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── The standing org and the editor's form ────────────────────────────────────────────────
|
|
172
|
+
//
|
|
173
|
+
// "ChatPanel Engineering" (architecture-pillars.md §12.2) — the first standing agents,
|
|
174
|
+
// offered as starters the way `research` and `review` are for teams. Every Implementer is
|
|
175
|
+
// this ONE card with a different working directory: the org repo's `agents/*.json` (§14.3)
|
|
176
|
+
// is where a company keeps one per repo. Engines are `auto` / `harness:<id>` so a starter
|
|
177
|
+
// does not name a provider a person may not have; the Harness engines say `claude` because
|
|
178
|
+
// that is the bridge's id for Claude Code and the pilot's choice — change it on the card.
|
|
179
|
+
|
|
180
|
+
export const STARTER_AGENTS = Object.freeze([
|
|
181
|
+
{ id: 'architect', name: 'Architect', purpose: 'Reads the docs and the repos; writes the project page and the jobs.',
|
|
182
|
+
prompt: 'You are the Architect. Read the feature doc, ROADMAP.md, naming-revamp.md and architecture-pillars.md before deciding anything. Write the project page (goal, done-when, budget) and post one job per repo that must change, saying which repo and what the guard is. Propose a new agent type only when no one in the pool fits. Never run a shell.',
|
|
183
|
+
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
|
|
184
|
+
{ id: 'implementer', name: 'Implementer', purpose: 'Builds one job on a branch in one repo; runs the guard; posts on the thread. Never pushes to main, never publishes.',
|
|
185
|
+
prompt: 'You are an Implementer. Work only in the repository you were given, on the branch named for this job. Read the job thread first. Make the change, run the repository\'s guard (tools/test-*.mjs or npm test) until it passes, commit with a message that says what and why, and post a summary with the diff stat on the job thread. Do not push to main, do not publish, do not touch another repository.',
|
|
186
|
+
skills: [], grants: ['shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
187
|
+
{ id: 'reviewer', name: 'Reviewer', purpose: 'Reads the branch diff; posts findings on the thread. Approve or reject is a person\'s.',
|
|
188
|
+
prompt: 'You are the Reviewer. Read the diff of the job\'s branch against its base. Post each finding as a reply on the job thread: what, where (file:line), why it matters, what to do instead. Do not edit files. Do not approve or reject — say what you found and let a person decide.',
|
|
189
|
+
skills: ['review'], grants: ['shell', 'scm:read'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
190
|
+
{ id: 'tester', name: 'Tester', purpose: 'Runs the repository\'s guard on the branch; reports the result. No writes.',
|
|
191
|
+
prompt: 'You are the Tester. Check out the job\'s branch in its worktree and run the repository\'s guard (tools/test-*.mjs, npm test, the build). Report pass/fail with the failing output verbatim. Do not change any file.',
|
|
192
|
+
skills: [], grants: ['shell', 'scm:read'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
193
|
+
{ id: 'librarian', name: 'Librarian', purpose: 'Before every job: what was already done for this, and where.',
|
|
194
|
+
prompt: 'You are the Librarian. Before a job starts, search the runs, boards, briefs and docs for work that already covers it. Reply on the job thread with "already done in …" pointers and what could be reused or extended. Never do the job yourself.',
|
|
195
|
+
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'cheapest-that-clears' } }, appliesTo: ['jobs'] },
|
|
196
|
+
{ id: 'scribe', name: 'Scribe', purpose: 'Writes what the run taught into the roadmap and the status row — proposed, a person lands it.',
|
|
197
|
+
prompt: 'You are the Scribe. From the run\'s board and scorecards, write the "what this run taught" paragraph for the feature doc, the ROADMAP.md entry and the IMPLEMENTATION-STATUS.md row. Post them as a proposal on the thread; a person lands them. Say plainly what did not work.',
|
|
198
|
+
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'balanced' } }, appliesTo: ['jobs', 'notes'] },
|
|
199
|
+
{ id: 'release', name: 'Release', purpose: 'Version bump and changelog on the merged branch; asks before publish or push.',
|
|
200
|
+
prompt: 'You are Release. On the merged branch: bump the version, write the changelog line, run the guard. Before `npm publish` or any `git push`, stop and ask on the thread — a person answers. Never publish or push without that answer.',
|
|
201
|
+
skills: [], grants: ['shell', 'fs:write', 'scm:read', 'scm:push'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
/** Fresh copies — a starter is a template, never the stored record. */
|
|
205
|
+
export function starterAgents() {
|
|
206
|
+
return STARTER_AGENTS.map((a) => JSON.parse(JSON.stringify(a)));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A blank agent for the editor. */
|
|
210
|
+
export function blankAgent() {
|
|
211
|
+
return { id: '', name: '', purpose: '', prompt: '', skills: [], grants: ['none'], engine: { kind: 'auto', policy: { prefer: 'balanced' } }, appliesTo: ['jobs'], memoryScope: '' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The editor's form → an agent, or the errors. Grants and skills come as text ("web, data",
|
|
216
|
+
* "review, graphify"); the engine as the form's own shape (`{ kind, model, providerId,
|
|
217
|
+
* harnessId, prefer }`) or a string; a blank id is slugged from the name.
|
|
218
|
+
*/
|
|
219
|
+
export function agentFromForm(form) {
|
|
220
|
+
const f = form || {};
|
|
221
|
+
const id = String(f.id || '').trim() || slugAgentId(f.name);
|
|
222
|
+
const engine = isRecord(f.engine)
|
|
223
|
+
? (f.engine.kind === 'auto' ? { kind: 'auto', policy: { prefer: f.engine.prefer || f.engine.policy?.prefer || 'balanced', ...(f.engine.policy || {}) } } : f.engine)
|
|
224
|
+
: f.engine;
|
|
225
|
+
const agent = {
|
|
226
|
+
id,
|
|
227
|
+
name: String(f.name || '').trim() || id,
|
|
228
|
+
purpose: String(f.purpose || '').trim(),
|
|
229
|
+
prompt: String(f.prompt || ''),
|
|
230
|
+
skills: list(f.skills, 80, MAX_SKILLS),
|
|
231
|
+
grants: list(f.grants, 80, 32).length ? list(f.grants, 80, 32) : ['none'],
|
|
232
|
+
engine,
|
|
233
|
+
...(f.egress ? { egress: f.egress } : {}),
|
|
234
|
+
appliesTo: Array.isArray(f.appliesTo) && f.appliesTo.length ? f.appliesTo : ['jobs'],
|
|
235
|
+
...(f.memoryScope ? { memoryScope: f.memoryScope } : {}),
|
|
236
|
+
...(f.workdir ? { workdir: f.workdir } : {}),
|
|
237
|
+
createdBy: f.createdBy || 'person',
|
|
238
|
+
enabled: f.enabled !== false,
|
|
239
|
+
...(f.origin && isRecord(f.origin) ? { origin: f.origin } : {}),
|
|
240
|
+
...(f.createdAt ? { createdAt: f.createdAt } : {}),
|
|
241
|
+
};
|
|
242
|
+
const v = validateAgent(agent);
|
|
243
|
+
return v.ok ? { ok: true, agent: normalizeAgent(agent) } : { ok: false, errors: v.errors };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Which agents apply to jobs — the pool a job board recruits from. */
|
|
247
|
+
export function poolFor(agents, surface = 'jobs') {
|
|
248
|
+
return (Array.isArray(agents) ? agents : []).filter((a) => a && a.enabled !== false && (Array.isArray(a.appliesTo) ? a.appliesTo : ['jobs']).includes(surface));
|
|
249
|
+
}
|
package/attribution.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// AUTHORSHIP — who wrote which run of a document, and the versions you can go back to.
|
|
2
|
+
//
|
|
3
|
+
// Moved here from the extension's `notes-provenance.js` unchanged in behaviour. It was
|
|
4
|
+
// always platform-free: a run-list `[{ len, author, at }]` that sums to the body length and
|
|
5
|
+
// shifts naturally as text is inserted or deleted, with no absolute offsets to fix up. The
|
|
6
|
+
// desktop needed exactly the same answers, and a second implementation of "who wrote this"
|
|
7
|
+
// would have disagreed with the first on precisely the edits that matter.
|
|
8
|
+
//
|
|
9
|
+
// WHY RUNS AND NOT OFFSETS. An offset-based ledger has to be repaired after every edit, and
|
|
10
|
+
// the repair is where the bugs live. A run-list is repaired BY the edit: replacing [s,e)
|
|
11
|
+
// with n characters is a splice, and everything after it moves without being touched.
|
|
12
|
+
//
|
|
13
|
+
// The versioning half is new here and belongs beside it, because a version snapshot carries
|
|
14
|
+
// its ledger — restoring a body without its attribution would silently reattribute an
|
|
15
|
+
// agent's paragraphs to the person who pressed Restore.
|
|
16
|
+
|
|
17
|
+
export const HUMAN = 'You';
|
|
18
|
+
|
|
19
|
+
export function blankAttribution(len, author = HUMAN, at = 0) {
|
|
20
|
+
return len > 0 ? [{ len, author, at }] : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The minimal replaced range: [start,end) of `prev` became `insLen` new chars in `next`.
|
|
24
|
+
export function diffRange(prev, next) {
|
|
25
|
+
const max = Math.min(prev.length, next.length);
|
|
26
|
+
let s = 0; while (s < max && prev[s] === next[s]) s++;
|
|
27
|
+
let e = 0; while (e < max - s && prev[prev.length - 1 - e] === next[next.length - 1 - e]) e++;
|
|
28
|
+
return { start: s, end: prev.length - e, insLen: next.length - s - e };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function mergeRuns(runs) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const r of runs) {
|
|
34
|
+
if (!r.len) continue;
|
|
35
|
+
const last = out[out.length - 1];
|
|
36
|
+
if (last && last.author === r.author && last.at === r.at) last.len += r.len;
|
|
37
|
+
else out.push({ len: r.len, author: r.author, at: r.at });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function spliceAttribution(runs, start, end, insLen, author, at) {
|
|
43
|
+
const before = [], after = [];
|
|
44
|
+
let pos = 0;
|
|
45
|
+
for (const r of runs) {
|
|
46
|
+
const rStart = pos, rEnd = pos + r.len;
|
|
47
|
+
if (rEnd <= start) before.push(r);
|
|
48
|
+
else if (rStart >= end) after.push(r);
|
|
49
|
+
else {
|
|
50
|
+
if (rStart < start) before.push({ len: start - rStart, author: r.author, at: r.at });
|
|
51
|
+
if (rEnd > end) after.push({ len: rEnd - end, author: r.author, at: r.at });
|
|
52
|
+
}
|
|
53
|
+
pos = rEnd;
|
|
54
|
+
}
|
|
55
|
+
return mergeRuns([...before, ...(insLen ? [{ len: insLen, author, at }] : []), ...after]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Attribute the diff prev→next to `author`. Returns the updated run-list (unchanged
|
|
59
|
+
// reference-wise only when there was no change).
|
|
60
|
+
export function applyAttribution(runs, prev, next, author, at) {
|
|
61
|
+
const cur = Array.isArray(runs) && runs.length ? runs : blankAttribution(prev.length);
|
|
62
|
+
const { start, end, insLen } = diffRange(prev, next);
|
|
63
|
+
if (start === end && !insLen) return cur; // no change
|
|
64
|
+
return spliceAttribution(cur, start, end, insLen, author, at);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function attributionSummary(runs) {
|
|
68
|
+
const by = new Map();
|
|
69
|
+
let total = 0;
|
|
70
|
+
for (const r of runs || []) { by.set(r.author, (by.get(r.author) || 0) + r.len); total += r.len; }
|
|
71
|
+
return {
|
|
72
|
+
by: [...by.entries()].map(([author, chars]) => ({ author, chars })).sort((a, b) => b.chars - a.chars),
|
|
73
|
+
total,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Adopt a stored ledger only if it still matches the body length (a note edited by an
|
|
78
|
+
// older build, or imported, won't have one) — otherwise seed the whole body as You.
|
|
79
|
+
export function normalizeAttribution(runs, bodyLen, at) {
|
|
80
|
+
if (Array.isArray(runs) && runs.length && runs.reduce((n, r) => n + (r.len || 0), 0) === bodyLen) return mergeRuns(runs);
|
|
81
|
+
return blankAttribution(bodyLen, HUMAN, at);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Versions — the snapshots you can go back to
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Forty is what the extension keeps. Enough to undo an afternoon, bounded enough to store. */
|
|
89
|
+
export const MAX_VERSIONS = 40;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Append a snapshot of `body` and its ledger, and return the new list.
|
|
93
|
+
*
|
|
94
|
+
* Two rules, both about not filling the list with noise:
|
|
95
|
+
* · a body identical to the newest snapshot is not a new version;
|
|
96
|
+
* · a ledger that does not sum to this body's length is not this body's ledger, so it is
|
|
97
|
+
* seeded blank rather than carried across — a mismatched ledger would make a later
|
|
98
|
+
* restore attribute the wrong spans to the wrong authors.
|
|
99
|
+
*/
|
|
100
|
+
export function pushVersion(versions, { body, attribution = null, by = HUMAN, label = '', at = Date.now() } = {}) {
|
|
101
|
+
const list = Array.isArray(versions) ? versions : [];
|
|
102
|
+
const text = String(body ?? '');
|
|
103
|
+
const last = list[list.length - 1];
|
|
104
|
+
if (last && last.body === text) return list;
|
|
105
|
+
const ledger = (Array.isArray(attribution) && attribution.reduce((n, r) => n + (r.len || 0), 0) === text.length)
|
|
106
|
+
? attribution
|
|
107
|
+
: blankAttribution(text.length, by, at);
|
|
108
|
+
return [...list, { body: text, attribution: ledger, at, by, label: label || by }].slice(-MAX_VERSIONS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Restore version `index`, keeping the current draft as a snapshot first so the restore is
|
|
113
|
+
* itself undoable.
|
|
114
|
+
*
|
|
115
|
+
* The guard against re-snapshotting matters more than it looks: flipping between two
|
|
116
|
+
* versions (A→B→A→B) would otherwise push an identical "Before restore" row every time and
|
|
117
|
+
* push the version you were trying to reach off the end of a bounded list.
|
|
118
|
+
*/
|
|
119
|
+
export function restoreVersion(versions, index, { currentBody, currentAttribution = null, at = Date.now(), label = 'Before restore' } = {}) {
|
|
120
|
+
const list = Array.isArray(versions) ? versions : [];
|
|
121
|
+
const target = list[index];
|
|
122
|
+
if (!target) return null;
|
|
123
|
+
const draft = String(currentBody ?? '');
|
|
124
|
+
const next = list.some((v) => v.body === draft)
|
|
125
|
+
? list
|
|
126
|
+
: pushVersion(list, { body: draft, attribution: currentAttribution, by: HUMAN, label, at });
|
|
127
|
+
return {
|
|
128
|
+
body: target.body,
|
|
129
|
+
attribution: normalizeAttribution(target.attribution, target.body.length, target.at || at),
|
|
130
|
+
versions: next,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/client-prefs.js
CHANGED
|
@@ -22,7 +22,16 @@ export const PREF_SECTIONS = Object.freeze([
|
|
|
22
22
|
{ id: 'skills', label: 'Skills', path: ['skills'], kind: 'array' },
|
|
23
23
|
{ id: 'skillDirs', label: 'Skill folders', path: ['ui', 'skillDirs'], kind: 'array' },
|
|
24
24
|
{ id: 'recipes', label: 'Recipes', path: ['recipes'], kind: 'array' },
|
|
25
|
-
{ id: 'teams', label: '
|
|
25
|
+
{ id: 'teams', label: 'Teams', path: ['teams'], kind: 'array' },
|
|
26
|
+
// The agent pool (agent.js). NOT `settings.agents` — that key is the extension's harness
|
|
27
|
+
// list (its bridge "agents"), which the naming rule keeps; the pool lives beside it.
|
|
28
|
+
{ id: 'agents', label: 'Agents', path: ['agentPool'], kind: 'array' },
|
|
29
|
+
// SCM connections (scm-connection.js): kind, host, secret REF — never the token, which
|
|
30
|
+
// stays in the machine's keychain / the bridge's secret store and does not travel.
|
|
31
|
+
{ id: 'connections', label: 'Connections', path: ['connections'], kind: 'array' },
|
|
32
|
+
// The project pages (F8 §12): the goal, its stakeholder, budget, repos, gate. Jobs and runs
|
|
33
|
+
// fold on the gateway's project record, not here.
|
|
34
|
+
{ id: 'projects', label: 'Projects', path: ['projects'], kind: 'array' },
|
|
26
35
|
{ id: 'webSearch', label: 'Web search', path: ['ui', 'webSearch'], kind: 'object' },
|
|
27
36
|
{ id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
|
|
28
37
|
{ id: 'suggestions', label: 'Smart suggestions', path: ['ui', 'suggestions'], kind: 'object' },
|
package/engine.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// An ENGINE — what actually runs an agent's turns — as a declaration, and its one-line label.
|
|
2
|
+
//
|
|
3
|
+
// The model is a variable, not a constant (architecture-pillars.md §13). An agent card names
|
|
4
|
+
// the engine it runs on, and there are four ways to say it:
|
|
5
|
+
//
|
|
6
|
+
// { kind: 'model', providerId?, model } an endpoint the client calls; `providerId` is
|
|
7
|
+
// the endpoint / gateway destination, because the
|
|
8
|
+
// same model at two providers is two engines
|
|
9
|
+
// { kind: 'harness', harnessId, model? } a CLI coding agent the bridge runs (Claude
|
|
10
|
+
// Code, Codex, …) — ChatPanel delegates a whole
|
|
11
|
+
// task to it; `model` is what it was asked to run
|
|
12
|
+
// { kind: 'auto', policy } the recruiter picks, by policy, from the
|
|
13
|
+
// engine cards (§13.4) — the default
|
|
14
|
+
// { kind: 'assistant' } the built-in Assistant: whatever model the chat
|
|
15
|
+
// is on right now (`engineOf` resolves it)
|
|
16
|
+
//
|
|
17
|
+
// The RECORD keeps a flatter shape — `{ kind: 'model'|'harness', id, model? }`, see
|
|
18
|
+
// scorecard.js `normalizeEngine` — because a record says what DID run, and `auto` and
|
|
19
|
+
// `assistant` never run anything themselves. `engineRef` maps a spec to that shape once a
|
|
20
|
+
// choice was made, so the scorecard's `byEngine` and the model ledger key the same way.
|
|
21
|
+
//
|
|
22
|
+
// Pure, dependency-free; team.js and agent.js both import from here, never from each other.
|
|
23
|
+
|
|
24
|
+
export const ENGINE_KINDS = Object.freeze(['model', 'harness', 'auto', 'assistant']);
|
|
25
|
+
export const ROUTE_PREFERS = Object.freeze(['cheapest-that-clears', 'best-quality', 'fastest', 'balanced']);
|
|
26
|
+
export const HARNESS_ID_RE = /^[a-zA-Z0-9_.:@+-]{1,120}$/;
|
|
27
|
+
|
|
28
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
29
|
+
const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).trim().slice(0, n) || undefined);
|
|
30
|
+
const num = (v) => { const n = Number(v); return v === '' || v == null || !Number.isFinite(n) ? undefined : n; };
|
|
31
|
+
const refs = (xs) => (Array.isArray(xs) ? [...new Set(xs.map((x) => str(typeof x === 'string' ? x : engineKeyOf(x), 200)).filter(Boolean))] : undefined);
|
|
32
|
+
|
|
33
|
+
/** A routing policy, normalized: an unknown preference is `balanced`; floors and ceilings are numbers or absent. */
|
|
34
|
+
export function normalizePolicy(p) {
|
|
35
|
+
const src = isRecord(p) ? p : {};
|
|
36
|
+
const floor = {}; const ceiling = {};
|
|
37
|
+
const q = num(src.floor?.quality); if (q !== undefined) floor.quality = Math.max(0, Math.min(1, q));
|
|
38
|
+
const av = num(src.floor?.availability); if (av !== undefined) floor.availability = Math.max(0, Math.min(1, av));
|
|
39
|
+
const c = num(src.ceiling?.costPerTask); if (c !== undefined && c >= 0) ceiling.costPerTask = c;
|
|
40
|
+
const l = num(src.ceiling?.latencyMs); if (l !== undefined && l >= 0) ceiling.latencyMs = Math.round(l);
|
|
41
|
+
const allow = refs(src.allow); const deny = refs(src.deny);
|
|
42
|
+
return {
|
|
43
|
+
prefer: ROUTE_PREFERS.includes(src.prefer) ? src.prefer : 'balanced',
|
|
44
|
+
...(Object.keys(floor).length ? { floor } : {}),
|
|
45
|
+
...(Object.keys(ceiling).length ? { ceiling } : {}),
|
|
46
|
+
...(allow?.length ? { allow } : {}),
|
|
47
|
+
...(deny?.length ? { deny } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An engine spec as stored. A string is read the obvious way — `assistant`, `auto`, a
|
|
53
|
+
* `harness:<id>` / `model:<id>` prefix, or a bare model id — because a person types these
|
|
54
|
+
* and a model proposes them in prose. Anything unreadable is `auto`, the honest default.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeEngineSpec(e) {
|
|
57
|
+
if (e == null || e === '') return { kind: 'auto', policy: normalizePolicy() };
|
|
58
|
+
if (typeof e === 'string') {
|
|
59
|
+
const s = e.trim();
|
|
60
|
+
if (s === 'assistant' || s === 'auto') return s === 'assistant' ? { kind: 'assistant' } : { kind: 'auto', policy: normalizePolicy() };
|
|
61
|
+
const m = /^(model|harness):(.+)$/.exec(s);
|
|
62
|
+
if (m) return m[1] === 'harness' ? { kind: 'harness', harnessId: m[2].trim() } : { kind: 'model', model: m[2].trim() };
|
|
63
|
+
return { kind: 'model', model: s };
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(e)) return { kind: 'auto', policy: normalizePolicy() };
|
|
66
|
+
const kind = ENGINE_KINDS.includes(e.kind) ? e.kind : (e.harnessId ? 'harness' : e.model ? 'model' : e.policy ? 'auto' : 'auto');
|
|
67
|
+
if (kind === 'assistant') return { kind };
|
|
68
|
+
if (kind === 'auto') return { kind, policy: normalizePolicy(e.policy) };
|
|
69
|
+
if (kind === 'harness') {
|
|
70
|
+
const harnessId = str(e.harnessId || e.id, 120);
|
|
71
|
+
if (!harnessId) return { kind: 'auto', policy: normalizePolicy() };
|
|
72
|
+
const model = str(e.model, 200);
|
|
73
|
+
return { kind, harnessId, ...(model ? { model } : {}) };
|
|
74
|
+
}
|
|
75
|
+
const model = str(e.model || e.id, 200);
|
|
76
|
+
if (!model) return { kind: 'auto', policy: normalizePolicy() };
|
|
77
|
+
const providerId = str(e.providerId || e.destination || e.endpointId, 120);
|
|
78
|
+
return { kind: 'model', ...(providerId ? { providerId } : {}), model };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Is this a spec a validator should accept? Returns the errors, with a prefix. */
|
|
82
|
+
export function validateEngineSpec(e, where = 'engine') {
|
|
83
|
+
const errors = [];
|
|
84
|
+
if (e == null || e === '') return errors;
|
|
85
|
+
if (typeof e === 'string') return errors; // every string reads as something
|
|
86
|
+
if (!isRecord(e)) return [`${where}: a string or an object`];
|
|
87
|
+
if (e.kind !== undefined && !ENGINE_KINDS.includes(e.kind)) errors.push(`${where}.kind: one of ${ENGINE_KINDS.join(', ')}`);
|
|
88
|
+
if (e.kind === 'harness' && !str(e.harnessId || e.id)) errors.push(`${where}.harnessId: which harness`);
|
|
89
|
+
if (e.kind === 'harness' && str(e.harnessId || e.id) && !HARNESS_ID_RE.test(String(e.harnessId || e.id).trim())) errors.push(`${where}.harnessId: a short identifier`);
|
|
90
|
+
if (e.kind === 'model' && !str(e.model || e.id)) errors.push(`${where}.model: which model`);
|
|
91
|
+
if (e.kind === 'auto' && e.policy !== undefined && !isRecord(e.policy)) errors.push(`${where}.policy: an object`);
|
|
92
|
+
if (e.kind === 'auto' && isRecord(e.policy) && e.policy.prefer !== undefined && !ROUTE_PREFERS.includes(e.policy.prefer)) errors.push(`${where}.policy.prefer: one of ${ROUTE_PREFERS.join(', ')}`);
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The record's shape for a spec that names something concrete — `{ kind, id, model? }`,
|
|
98
|
+
* the same fields scorecard.js keys `byEngine` on and the model ledger is keyed by. `auto`
|
|
99
|
+
* and `assistant` have no ref: nothing ran yet.
|
|
100
|
+
*/
|
|
101
|
+
export function engineRef(spec) {
|
|
102
|
+
const s = normalizeEngineSpec(spec);
|
|
103
|
+
if (s.kind === 'harness') return { kind: 'harness', id: s.harnessId, ...(s.model ? { model: s.model } : {}) };
|
|
104
|
+
if (s.kind === 'model') return s.providerId ? { kind: 'model', id: s.providerId, model: s.model } : { kind: 'model', id: s.model };
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The ledger key of a spec, or null when it names nothing concrete. Same key as `engineKey` in scorecard.js. */
|
|
109
|
+
export function engineKeyOf(spec) {
|
|
110
|
+
const r = engineRef(spec);
|
|
111
|
+
return r ? `${r.kind}:${r.id}${r.model && r.model !== r.id ? `/${r.model}` : ''}` : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One phrase a person reads on a card: "Claude Code", "gpt-4o at openrouter", "auto · cheapest that clears", "the chat's model". */
|
|
115
|
+
export function describeEngine(spec, { harnessName = (id) => id, providerName = (id) => id } = {}) {
|
|
116
|
+
const s = normalizeEngineSpec(spec);
|
|
117
|
+
if (s.kind === 'assistant') return 'the chat’s model';
|
|
118
|
+
if (s.kind === 'auto') return `auto · ${s.policy.prefer.replace(/-/g, ' ')}`;
|
|
119
|
+
if (s.kind === 'harness') return `${harnessName(s.harnessId)}${s.model ? ` (${s.model})` : ''}`;
|
|
120
|
+
return `${s.model}${s.providerId ? ` at ${providerName(s.providerId)}` : ''}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The role tier today's appointers understand (`cheap` / `balanced` / `strong`) for a spec:
|
|
125
|
+
* the bridge to `prefer` until the recruiter routes by card (§13.4, step 5).
|
|
126
|
+
*/
|
|
127
|
+
export function tierOf(spec) {
|
|
128
|
+
const s = normalizeEngineSpec(spec);
|
|
129
|
+
if (s.kind !== 'auto') return 'balanced';
|
|
130
|
+
return { 'best-quality': 'strong', 'cheapest-that-clears': 'cheap', fastest: 'cheap', balanced: 'balanced' }[s.policy.prefer] || 'balanced';
|
|
131
|
+
}
|
package/gate.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The gate — how far a team may go without a person, as data an organisation configures.
|
|
2
|
+
//
|
|
3
|
+
// ChatPanel's own gate is the strictest setting; an organisation that trusts its pool more
|
|
4
|
+
// flips a flag. Nothing in the runner changes: project-run.js reads the gate at every step
|
|
5
|
+
// where it would otherwise ask, and `gateAllows` is the one question it asks. Lives in
|
|
6
|
+
// `.chatpanel/gate.json` in the org repo (pillars §14.3), optionally overridden per project.
|
|
7
|
+
|
|
8
|
+
export const AUTONOMY = Object.freeze(['propose', 'push', 'merge']);
|
|
9
|
+
export const HUMAN_FLAGS = Object.freeze(['merge', 'push', 'publish', 'budgetRaise', 'newAgent', 'newTool', 'writeBack', 'recruit']);
|
|
10
|
+
export const CHECKS = Object.freeze(['guard', 'review', 'tester', 'scan']);
|
|
11
|
+
|
|
12
|
+
/** ChatPanel's own: a branch push is not a release; everything else waits for a person. */
|
|
13
|
+
export const DEFAULT_GATE = Object.freeze({
|
|
14
|
+
autonomy: 'push',
|
|
15
|
+
human: Object.freeze({ merge: true, push: false, publish: true, budgetRaise: true, newAgent: true, newTool: true, writeBack: true, recruit: false }),
|
|
16
|
+
requiredBeforeMerge: Object.freeze(['guard', 'review', 'tester']),
|
|
17
|
+
branches: Object.freeze({ base: 'main', protected: Object.freeze(['main']) }),
|
|
18
|
+
budget: Object.freeze({ perProjectCap: null, perJobCap: null }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
22
|
+
|
|
23
|
+
export function validateGate(g, { partial = false } = {}) {
|
|
24
|
+
const errors = [];
|
|
25
|
+
if (!isRecord(g)) return { ok: false, errors: ['gate must be an object'] };
|
|
26
|
+
if (g.autonomy !== undefined && !AUTONOMY.includes(g.autonomy)) errors.push(`autonomy: one of ${AUTONOMY.join(', ')}`);
|
|
27
|
+
if (g.human !== undefined) {
|
|
28
|
+
if (!isRecord(g.human)) errors.push('human: an object of flags');
|
|
29
|
+
else for (const [k, v] of Object.entries(g.human)) { if (!HUMAN_FLAGS.includes(k)) errors.push(`human.${k}: unknown flag`); else if (typeof v !== 'boolean') errors.push(`human.${k}: true or false`); }
|
|
30
|
+
}
|
|
31
|
+
if (g.requiredBeforeMerge !== undefined && (!Array.isArray(g.requiredBeforeMerge) || g.requiredBeforeMerge.some((c) => !CHECKS.includes(c)))) errors.push(`requiredBeforeMerge: a list of ${CHECKS.join(', ')}`);
|
|
32
|
+
if (g.branches !== undefined && (!isRecord(g.branches) || (g.branches.protected !== undefined && !Array.isArray(g.branches.protected)))) errors.push('branches: { base, protected[] }');
|
|
33
|
+
if (g.budget !== undefined && !isRecord(g.budget)) errors.push('budget: { perProjectCap, perJobCap }');
|
|
34
|
+
if (!partial && g.autonomy === undefined) errors.push('autonomy: required');
|
|
35
|
+
return { ok: errors.length === 0, errors };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A gate over the default: a partial gate fills in from ChatPanel's own; a full one stands alone. */
|
|
39
|
+
export function normalizeGate(g, { partial = false, base = DEFAULT_GATE } = {}) {
|
|
40
|
+
const v = validateGate(g || {}, { partial: true });
|
|
41
|
+
if (!v.ok) throw new Error(`gate: ${v.errors.join('; ')}`);
|
|
42
|
+
const src = g || {};
|
|
43
|
+
const b = partial ? base : DEFAULT_GATE;
|
|
44
|
+
return {
|
|
45
|
+
autonomy: AUTONOMY.includes(src.autonomy) ? src.autonomy : b.autonomy,
|
|
46
|
+
human: { ...b.human, ...(isRecord(src.human) ? src.human : {}) },
|
|
47
|
+
requiredBeforeMerge: Array.isArray(src.requiredBeforeMerge) ? [...new Set(src.requiredBeforeMerge)] : [...b.requiredBeforeMerge],
|
|
48
|
+
branches: { base: String(src.branches?.base || b.branches.base), protected: Array.isArray(src.branches?.protected) ? [...new Set(src.branches.protected.map(String))] : [...b.branches.protected] },
|
|
49
|
+
budget: { perProjectCap: src.budget?.perProjectCap ?? b.budget.perProjectCap, perJobCap: src.budget?.perJobCap ?? b.budget.perJobCap },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The org's gate with a project's partial one over it. */
|
|
54
|
+
export function effectiveGate(orgGate = null, projectGate = null) {
|
|
55
|
+
const org = normalizeGate(orgGate || {}, { partial: true });
|
|
56
|
+
return projectGate ? normalizeGate(projectGate, { partial: true, base: org }) : org;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The one question the executive loop asks: may a team do `action` on its own?
|
|
61
|
+
* push · merge · publish · budgetRaise · newAgent · newTool · writeBack · recruit
|
|
62
|
+
* Returns `{ allowed, reason }`; a false answer is where the loop asks a person instead.
|
|
63
|
+
*/
|
|
64
|
+
export function gateAllows(gate, action, { branch = null } = {}) {
|
|
65
|
+
const g = normalizeGate(gate || {}, { partial: true });
|
|
66
|
+
if (action === 'push' || action === 'merge') {
|
|
67
|
+
if (branch && g.branches.protected.includes(branch)) return { allowed: false, reason: `${branch} is protected — a person merges` };
|
|
68
|
+
const far = AUTONOMY.indexOf(g.autonomy);
|
|
69
|
+
if (action === 'push' && far < AUTONOMY.indexOf('push')) return { allowed: false, reason: 'the gate allows proposing only' };
|
|
70
|
+
if (action === 'merge' && far < AUTONOMY.indexOf('merge')) return { allowed: false, reason: `the gate allows up to ${g.autonomy}` };
|
|
71
|
+
}
|
|
72
|
+
if (HUMAN_FLAGS.includes(action) && g.human[action]) return { allowed: false, reason: `a person decides ${action}` };
|
|
73
|
+
return { allowed: true, reason: `the gate allows ${action}` };
|
|
74
|
+
}
|
package/index.js
CHANGED
|
@@ -192,13 +192,24 @@ export { recipeToolProvider, recipeToolSpec, describeRecipeForApproval, RECIPE_T
|
|
|
192
192
|
// Agent teams (F8): a team is data, a run is a turn of turns under a budget, members talk
|
|
193
193
|
// through a typed board, nothing lands without a person.
|
|
194
194
|
export { createBudget, validateBudget, normalizeBudget, usageOf, BudgetError, BUDGET_DIMENSIONS } from './budget.js';
|
|
195
|
-
export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows, describeRole, TeamError, ROLE_MODES, MERGE_POLICIES, PLAN_MODES, GRANTABLE, STARTER_TEAMS, starterTeams, blankTeam, teamFromForm, slugTeamName } from './team.js';
|
|
195
|
+
export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows, scmAllows, describeRole, TeamError, ROLE_MODES, MERGE_POLICIES, PLAN_MODES, GRANTABLE, WORK_GRANTS, GRANT_RE, AGENT_REF_RE, STARTER_TEAMS, starterTeams, blankTeam, teamFromForm, slugTeamName } from './team.js';
|
|
196
196
|
export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
|
|
197
197
|
export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS, THREAD_KINDS, THREAD_STATUSES, POST_KINDS, POST_STATUSES, ASK_TYPES, emptyBoardState, foldBoard, findingsOf } from './team-board.js';
|
|
198
198
|
export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
|
|
199
199
|
export { createRunCache, withRunCache } from './team-cache.js';
|
|
200
200
|
export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES } from './team-record.js';
|
|
201
|
-
export {
|
|
201
|
+
export { validateProject, normalizeProject, defineProject, canTransition as canProjectTransition, blankProject, projectFromForm, emptyProjectRecord, foldProject, projectProgress, ProjectError, PROJECT_STATUSES, PROJECT_ID_RE } from './project.js';
|
|
202
|
+
// Job POSTINGS (F8 §12) — `jobs.js` is the scheduler and keeps `defineJob`; a posting is a JobPost here.
|
|
203
|
+
export { validateJob as validateJobPost, normalizeJob as normalizeJobPost, defineJob as defineJobPost, canTransition as canJobPostTransition, applyAll, jobToRole, readyJobs, blankJob as blankJobPost, jobFromForm as jobPostFromForm, JobError as JobPostError, JOB_STATUSES as JOB_POST_STATUSES, JOB_ID_RE as JOB_POST_ID_RE } from './job.js';
|
|
204
|
+
export { validateGate, normalizeGate, effectiveGate, gateAllows, DEFAULT_GATE, AUTONOMY, HUMAN_FLAGS, CHECKS } from './gate.js';
|
|
205
|
+
export { canonical, sha256, makeEntry, verifyChain, attest, verifyAttested, summarize, fit, adjustSummary, normalizeEngine, engineKey, normalizeScm, SCORECARD_ENTRY_KINDS, ROLE_KINDS, SCORECARD_VERSION, ENGINE_KINDS as RECORD_ENGINE_KINDS } from './scorecard.js';
|
|
206
|
+
export { ENGINE_KINDS as ENGINE_SPEC_KINDS, ROUTE_PREFERS, normalizePolicy, normalizeEngineSpec, validateEngineSpec, engineRef, engineKeyOf, describeEngine, tierOf } from './engine.js';
|
|
207
|
+
export { AGENT_ID_RE, APPLIES_TO, EGRESS_CLASSES, ASSISTANT_ID, AgentError, validateAgent, normalizeAgent, defineAgent, assistantAgent, engineOf, describeAgent, slugAgentId, resolveTeam, STARTER_AGENTS, starterAgents, blankAgent, agentFromForm, poolFor } from './agent.js';
|
|
208
|
+
export { LEDGER_VERSION, LEDGER_ENTRY_KINDS, DECLINE_REASONS, WITHDRAW_AFTER, ledgerKey, normalizeCall, makeLedgerEntry, summarizeEngine } from './model-ledger.js';
|
|
209
|
+
// Recruiting (F8 §12.2.4, pillars §13.4): the pool applies at once; an (agent, engine) pair is recruited; the evaluator is one optional structured call.
|
|
210
|
+
export { RECRUIT_SCHEMA, MIN_FIT, engineRow, engineRows, needForJob, routeFor, engineWorth, applications as jobApplications, evaluatorPrompt, parseEvaluation, decide as decideRecruit, proposalFromNeeds, proposalToAgent, carveBudget, recruitEvents, recruitJob } from './recruit.js';
|
|
211
|
+
export { DEFAULT_MIN_CALLS, cardOverride, applyCard } from './model-candidates.js';
|
|
212
|
+
export { SCM_KINDS, validateConnection, normalizeConnection, parseRemote, connectionFor, branchFor, worktreeDirFor, credentialEnv, describeConnection, blankConnection, connectionFromForm } from './scm-connection.js';
|
|
202
213
|
export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
|
|
203
214
|
export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
|
|
204
215
|
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|