@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// An SCM CONNECTION — how a harness engine reaches the organisation's source-control hub —
|
|
2
|
+
// and the two names a job gets in it: its branch and its worktree directory.
|
|
3
|
+
// (architecture-pillars.md §14: the repository is the source of truth.)
|
|
4
|
+
//
|
|
5
|
+
// ChatPanel is not a git host and not a git client. The harness runs `git`, `gh`, `glab`;
|
|
6
|
+
// ChatPanel brings the CREDENTIAL, the WORKTREE, the BRANCH NAME and the RECORD. This module
|
|
7
|
+
// is the pure part: what a connection record holds, how a remote URL maps to one, what a
|
|
8
|
+
// job's branch is called, and the environment that hands a token to git for ONE process
|
|
9
|
+
// without writing it anywhere.
|
|
10
|
+
//
|
|
11
|
+
// THE TOKEN IS NEVER IN THE RECORD. A connection stores a `secretRef` — the name under which
|
|
12
|
+
// the machine's keychain (or the bridge's secret store) holds the token. The record travels
|
|
13
|
+
// with the client-prefs document (`connections` section) so both clients show the same
|
|
14
|
+
// connections; the secret does not travel, and `normalizeConnection` REFUSES a record that
|
|
15
|
+
// carries one, so a settings export, a sync, or a prompt can never carry it either.
|
|
16
|
+
|
|
17
|
+
export const SCM_KINDS = Object.freeze(['github', 'github-enterprise', 'gitlab', 'bitbucket', 'gitea', 'azure-devops', 'git']);
|
|
18
|
+
export const CONNECTION_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
19
|
+
export const SECRET_REF_RE = /^[a-zA-Z0-9_.:@/-]{1,200}$/;
|
|
20
|
+
const SECRET_FIELDS = ['token', 'password', 'secret', 'pat', 'apiKey', 'key'];
|
|
21
|
+
const DEFAULT_HOST = { github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org', 'azure-devops': 'dev.azure.com' };
|
|
22
|
+
|
|
23
|
+
export class ScmError extends Error {
|
|
24
|
+
constructor(code, message) { super(message); this.name = 'ScmError'; this.code = code; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
/** The host a connection serves: its baseUrl's, or the kind's public host. */
|
|
30
|
+
export function hostOf(conn) {
|
|
31
|
+
const url = String(conn?.baseUrl || '').trim();
|
|
32
|
+
if (url) { try { return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host.toLowerCase(); } catch { return url.toLowerCase().replace(/^https?:\/\//, '').split('/')[0]; } }
|
|
33
|
+
return DEFAULT_HOST[conn?.kind] || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateConnection(conn) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
if (!isRecord(conn)) return { ok: false, errors: ['connection must be an object'] };
|
|
39
|
+
if (!CONNECTION_ID_RE.test(String(conn.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
40
|
+
if (!SCM_KINDS.includes(conn.kind)) errors.push(`kind: one of ${SCM_KINDS.join(', ')}`);
|
|
41
|
+
const needsHost = ['github-enterprise', 'gitea', 'git'].includes(conn.kind);
|
|
42
|
+
if (needsHost && !String(conn.baseUrl || '').trim()) errors.push('baseUrl: the hub\'s URL');
|
|
43
|
+
if (conn.baseUrl && !/^(https?:\/\/)?[a-z0-9.-]+(:\d+)?(\/[^\s]*)?$/i.test(String(conn.baseUrl).trim())) errors.push('baseUrl: a host or URL');
|
|
44
|
+
const leaked = SECRET_FIELDS.filter((k) => conn[k] !== undefined && conn[k] !== null && conn[k] !== '');
|
|
45
|
+
if (leaked.length) errors.push(`${leaked.join(', ')}: a connection never stores a secret — store it in the keychain and reference it by secretRef`);
|
|
46
|
+
if (conn.secretRef !== undefined && conn.secretRef !== null && conn.secretRef !== '' && !SECRET_REF_RE.test(String(conn.secretRef))) errors.push('secretRef: a keychain / secret-store name');
|
|
47
|
+
if (conn.reach !== undefined && !Array.isArray(conn.reach)) errors.push('reach: a list of remotes or owners this connection may be used for');
|
|
48
|
+
return { ok: errors.length === 0, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The stored form. Throws on a record that carries a secret. */
|
|
52
|
+
export function normalizeConnection(conn) {
|
|
53
|
+
const v = validateConnection(conn);
|
|
54
|
+
if (!v.ok) throw new ScmError('INVALID', v.errors.join('; '));
|
|
55
|
+
const id = String(conn.id);
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
kind: conn.kind,
|
|
59
|
+
label: String(conn.label || conn.name || '').trim().slice(0, 80) || `${conn.kind} · ${hostOf(conn) || id}`,
|
|
60
|
+
...(conn.baseUrl ? { baseUrl: String(conn.baseUrl).trim().replace(/\/+$/, '') } : {}),
|
|
61
|
+
// The secret's NAME — `chatpanel:scm:<id>` by default — under which the keychain holds it.
|
|
62
|
+
secretRef: String(conn.secretRef || '').trim() || `chatpanel:scm:${id}`,
|
|
63
|
+
...(conn.username ? { username: String(conn.username).trim().slice(0, 80) } : {}),
|
|
64
|
+
// Which remotes it may be used for: `owner/*`, `owner/name`, or a host. Empty = any on its host.
|
|
65
|
+
reach: (Array.isArray(conn.reach) ? conn.reach : []).map((r) => String(r).trim().toLowerCase()).filter(Boolean).slice(0, 64),
|
|
66
|
+
enabled: conn.enabled !== false,
|
|
67
|
+
...(conn.createdAt ? { createdAt: conn.createdAt } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A remote URL, read: `{ host, owner, name, protocol }` for https, ssh (`git@host:o/n.git`,
|
|
73
|
+
* `ssh://git@host/o/n`) and plain `host/o/n`. Credentials in the URL are dropped, never
|
|
74
|
+
* returned. Null for what cannot be read.
|
|
75
|
+
*/
|
|
76
|
+
export function parseRemote(url) {
|
|
77
|
+
const s = String(url || '').trim();
|
|
78
|
+
if (!s) return null;
|
|
79
|
+
let m = /^(?:ssh:\/\/)?(?:[\w.-]+@)?([a-z0-9.-]+)(?::\d+)?[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(s.replace(/^https?:\/\/(?:[^@/]+@)?/i, ''));
|
|
80
|
+
if (m && !/^https?:/i.test(s)) return { host: m[1].toLowerCase(), owner: m[2], name: m[3], protocol: /^ssh:|^[\w.-]+@/.test(s) ? 'ssh' : 'plain' };
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(s);
|
|
83
|
+
const parts = u.pathname.replace(/\.git$/, '').split('/').filter(Boolean);
|
|
84
|
+
if (parts.length < 2) return null;
|
|
85
|
+
// Azure DevOps: /org/project/_git/repo; GitLab subgroups: /a/b/c/repo — owner is all but the last.
|
|
86
|
+
const name = parts.at(-1);
|
|
87
|
+
const owner = parts.slice(0, -1).filter((p) => p !== '_git').join('/');
|
|
88
|
+
return { host: u.host.toLowerCase(), owner, name, protocol: u.protocol.replace(':', '') };
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The connection to use for a remote: enabled, same host, and — when the connection lists
|
|
94
|
+
* a reach — the remote's `owner/name` or `owner/*` in it. The most specific reach wins.
|
|
95
|
+
*/
|
|
96
|
+
export function connectionFor(remote, connections = []) {
|
|
97
|
+
const r = typeof remote === 'string' ? parseRemote(remote) : remote;
|
|
98
|
+
if (!r) return null;
|
|
99
|
+
const slug = `${r.owner}/${r.name}`.toLowerCase();
|
|
100
|
+
let best = null; let bestScore = -1;
|
|
101
|
+
for (const c of connections || []) {
|
|
102
|
+
if (!c || c.enabled === false) continue;
|
|
103
|
+
if (hostOf(c) !== r.host) continue;
|
|
104
|
+
const reach = Array.isArray(c.reach) ? c.reach : [];
|
|
105
|
+
let score = reach.length ? -1 : 0;
|
|
106
|
+
for (const x of reach) {
|
|
107
|
+
if (x === slug) score = Math.max(score, 3);
|
|
108
|
+
else if (x.endsWith('/*') && slug.startsWith(x.slice(0, -1))) score = Math.max(score, 2);
|
|
109
|
+
else if (x === r.host) score = Math.max(score, 1);
|
|
110
|
+
}
|
|
111
|
+
if (score > bestScore) { best = c; bestScore = score; }
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const slug = (s) => String(s || '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 48) || 'x';
|
|
117
|
+
|
|
118
|
+
/** A job's branch: `cp/<project>/<job>` — one branch per job, always under `cp/`, never a protected name. */
|
|
119
|
+
export function branchFor(projectId, jobId) { return `cp/${slug(projectId)}/${slug(jobId)}`; }
|
|
120
|
+
|
|
121
|
+
/** A job's worktree directory under the bridge's worktree root: `<project>/<job>`. */
|
|
122
|
+
export function worktreeDirFor(projectId, jobId) { return `${slug(projectId)}/${slug(jobId)}`; }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The environment that hands ONE process the token, scoped to the connection's host:
|
|
126
|
+
* git's `credential.<url>.helper` set through `GIT_CONFIG_*` (never a file, never the
|
|
127
|
+
* command line), a helper that reads the token from the environment, terminal prompts
|
|
128
|
+
* off, and the hub CLIs' own variables (`GH_TOKEN` / `GH_HOST`, `GITLAB_TOKEN`) so `gh` and
|
|
129
|
+
* `glab` work in the same process. The caller spawns with `{ ...process.env, ...env }` and
|
|
130
|
+
* the token dies with the process.
|
|
131
|
+
*/
|
|
132
|
+
export function credentialEnv(conn, token, { username = null } = {}) {
|
|
133
|
+
const c = conn || {};
|
|
134
|
+
const host = hostOf(c);
|
|
135
|
+
if (!host || !token) return {};
|
|
136
|
+
const user = username || c.username || ({ github: 'x-access-token', 'github-enterprise': 'x-access-token', gitlab: 'oauth2', bitbucket: 'x-token-auth', gitea: 'oauth2', 'azure-devops': 'pat' }[c.kind] || 'token');
|
|
137
|
+
const env = {
|
|
138
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
139
|
+
GIT_CONFIG_COUNT: '2',
|
|
140
|
+
GIT_CONFIG_KEY_0: `credential.https://${host}.helper`,
|
|
141
|
+
// A shell helper: on `get`, answer from the environment. Nothing is written anywhere.
|
|
142
|
+
GIT_CONFIG_VALUE_0: '!f() { if [ "$1" = get ]; then printf "username=%s\\npassword=%s\\n" "$CHATPANEL_SCM_USER" "$CHATPANEL_SCM_TOKEN"; fi; }; f',
|
|
143
|
+
GIT_CONFIG_KEY_1: `credential.https://${host}.useHttpPath`,
|
|
144
|
+
GIT_CONFIG_VALUE_1: 'false',
|
|
145
|
+
CHATPANEL_SCM_USER: user,
|
|
146
|
+
CHATPANEL_SCM_TOKEN: String(token),
|
|
147
|
+
};
|
|
148
|
+
if (c.kind === 'github' || c.kind === 'github-enterprise') {
|
|
149
|
+
env.GH_TOKEN = String(token);
|
|
150
|
+
if (c.kind === 'github-enterprise') { env.GH_ENTERPRISE_TOKEN = String(token); env.GH_HOST = host; }
|
|
151
|
+
}
|
|
152
|
+
if (c.kind === 'gitlab') { env.GITLAB_TOKEN = String(token); env.GITLAB_HOST = host; }
|
|
153
|
+
return env;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One line a person reads: "GitHub · github.com · acme/*". */
|
|
157
|
+
export function describeConnection(conn) {
|
|
158
|
+
const c = conn || {};
|
|
159
|
+
const kindLabel = { github: 'GitHub', 'github-enterprise': 'GitHub Enterprise', gitlab: 'GitLab', bitbucket: 'Bitbucket', gitea: 'Gitea', 'azure-devops': 'Azure DevOps', git: 'Git' }[c.kind] || c.kind;
|
|
160
|
+
return `${kindLabel} · ${hostOf(c)}${c.reach?.length ? ` · ${c.reach.join(', ')}` : ''}${c.enabled === false ? ' (off)' : ''}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function blankConnection() { return { id: '', kind: 'github', label: '', baseUrl: '', reach: [], username: '' }; }
|
|
164
|
+
|
|
165
|
+
/** The editor's form → a connection, or the errors. The token is NOT part of the form's record: the host stores it separately. */
|
|
166
|
+
export function connectionFromForm(form) {
|
|
167
|
+
const f = form || {};
|
|
168
|
+
const id = String(f.id || '').trim() || slug(f.label || `${f.kind}-${hostOf(f)}`);
|
|
169
|
+
const conn = {
|
|
170
|
+
id, kind: f.kind || 'github', label: String(f.label || '').trim(),
|
|
171
|
+
...(f.baseUrl ? { baseUrl: String(f.baseUrl).trim() } : {}),
|
|
172
|
+
...(f.username ? { username: String(f.username).trim() } : {}),
|
|
173
|
+
...(f.secretRef ? { secretRef: String(f.secretRef).trim() } : {}),
|
|
174
|
+
reach: String(Array.isArray(f.reach) ? f.reach.join(',') : f.reach || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean),
|
|
175
|
+
enabled: f.enabled !== false,
|
|
176
|
+
...(f.createdAt ? { createdAt: f.createdAt } : {}),
|
|
177
|
+
};
|
|
178
|
+
const v = validateConnection(conn);
|
|
179
|
+
return v.ok ? { ok: true, connection: normalizeConnection(conn) } : { ok: false, errors: v.errors };
|
|
180
|
+
}
|
package/scorecard.js
CHANGED
|
@@ -13,11 +13,59 @@
|
|
|
13
13
|
// against a job's needs with that record — the same function the evaluator starts from, so
|
|
14
14
|
// an application's fit has reasons a person can read and overrule.
|
|
15
15
|
//
|
|
16
|
+
// The model is a variable, not a constant (architecture-pillars.md §13): every task fact also
|
|
17
|
+
// says which ENGINE did it — a model endpoint or a harness (a CLI coding agent), and for a
|
|
18
|
+
// harness the model it was asked to run — so `summarize` can split the card by engine
|
|
19
|
+
// (`byEngine`) and a recruiter can tell an agent that did well on a small model from one
|
|
20
|
+
// that was carried by a large one. And where the task ran in a git checkout (§14), the fact
|
|
21
|
+
// carries `scm`: the branch and HEAD before and after, commits made, a PR when one was
|
|
22
|
+
// opened, and whether it merged — the one outcome that does not come from a judge.
|
|
23
|
+
//
|
|
16
24
|
// Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
|
|
17
25
|
|
|
18
26
|
export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
|
|
19
27
|
export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
|
|
20
28
|
export const SCORECARD_VERSION = 1;
|
|
29
|
+
export const ENGINE_KINDS = Object.freeze(['model', 'harness']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An engine as the record keeps it: `{ kind, id, model?, label? }`. `kind` is `model` (an
|
|
33
|
+
* endpoint the client calls) or `harness` (a CLI coding agent the bridge runs — `id` is the
|
|
34
|
+
* harness, `model` the model it was asked to run, when one was named). A host that does not
|
|
35
|
+
* say the kind gets `model`, which is the honest default for a bare model id. A string is
|
|
36
|
+
* an id.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeEngine(e) {
|
|
39
|
+
if (!e) return null;
|
|
40
|
+
const src = typeof e === 'string' ? { id: e } : e;
|
|
41
|
+
const id = String(src.id || src.harnessId || src.model || '').trim();
|
|
42
|
+
if (!id) return null;
|
|
43
|
+
const kind = ENGINE_KINDS.includes(src.kind) ? src.kind : (src.harnessId ? 'harness' : 'model');
|
|
44
|
+
const model = src.model != null && String(src.model).trim() && String(src.model) !== id ? String(src.model).trim() : undefined;
|
|
45
|
+
return { kind, id, ...(model ? { model } : {}), ...(src.label && String(src.label) !== id ? { label: String(src.label).slice(0, 120) } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One key per engine — what `byEngine` groups on and what the model ledger will be keyed by. */
|
|
49
|
+
export function engineKey(e) {
|
|
50
|
+
const n = normalizeEngine(e);
|
|
51
|
+
return n ? `${n.kind}:${n.id}${n.model ? `/${n.model}` : ''}` : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a task did in a checkout, as the record keeps it. Strings clipped, counts rounded. */
|
|
55
|
+
export function normalizeScm(s) {
|
|
56
|
+
if (!s || typeof s !== 'object') return null;
|
|
57
|
+
const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).slice(0, n));
|
|
58
|
+
const out = {
|
|
59
|
+
repo: str(s.repo, 300), remote: str(s.remote, 300), base: str(s.base, 120), branch: str(s.branch, 120),
|
|
60
|
+
head: str(s.head, 64), headAfter: str(s.headAfter, 64),
|
|
61
|
+
commits: s.commits != null ? Math.max(0, Math.round(Number(s.commits) || 0)) : undefined,
|
|
62
|
+
pr: str(s.pr, 300),
|
|
63
|
+
merged: s.merged === true ? true : s.merged === false ? false : undefined,
|
|
64
|
+
dirty: s.dirty === true ? true : s.dirty === false ? false : undefined,
|
|
65
|
+
};
|
|
66
|
+
for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
|
|
67
|
+
return Object.keys(out).length ? out : null;
|
|
68
|
+
}
|
|
21
69
|
|
|
22
70
|
const enc = new TextEncoder();
|
|
23
71
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
@@ -60,6 +108,8 @@ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } =
|
|
|
60
108
|
...(fact.runId ? { runId: String(fact.runId) } : {}),
|
|
61
109
|
...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
|
|
62
110
|
...(fact.model ? { model: String(fact.model) } : {}),
|
|
111
|
+
...(normalizeEngine(fact.engine) ? { engine: normalizeEngine(fact.engine) } : {}),
|
|
112
|
+
...(normalizeScm(fact.scm) ? { scm: normalizeScm(fact.scm) } : {}),
|
|
63
113
|
...(fact.size ? { size: sizeOf(fact.size) } : {}),
|
|
64
114
|
...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
|
|
65
115
|
...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
|
|
@@ -75,6 +125,7 @@ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } =
|
|
|
75
125
|
}
|
|
76
126
|
|
|
77
127
|
const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
|
|
128
|
+
const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
|
|
78
129
|
const sizeOf = (s) => ({ ms: Math.max(0, Math.round(Number(s.ms) || 0)), steps: Math.max(0, Math.round(Number(s.steps) || 0)), tools: Math.max(0, Math.round(Number(s.tools) || 0)), findings: Math.max(0, Math.round(Number(s.findings) || 0)), tokens: Math.max(0, Math.round(Number(s.tokens) || 0)) });
|
|
79
130
|
|
|
80
131
|
/** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
|
|
@@ -120,13 +171,53 @@ export function summarize(entries, { recent = 5 } = {}) {
|
|
|
120
171
|
const tools = new Set(); const withAgents = new Set(); const created = new Set();
|
|
121
172
|
const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
|
|
122
173
|
const models = new Map();
|
|
174
|
+
// Per engine: how many tasks, how they went, how big, and the ratings that were ABOUT a
|
|
175
|
+
// task on it. A rating names its task by `about` (the seq of the entry it rates), by
|
|
176
|
+
// taskId, or — failing both — by runId when that run had exactly one task on the card.
|
|
177
|
+
const engines = new Map(); // key -> { engine, tasks, done, failed, tokens, ratings[] }
|
|
178
|
+
const engineOfEntry = new Map(); // seq -> key
|
|
179
|
+
const byTask = new Map(); const byRun = new Map(); // taskId -> seq, runId -> [seq]
|
|
180
|
+
const scm = { tasks: 0, commits: 0, prs: 0, merged: 0 };
|
|
123
181
|
for (const e of list) {
|
|
124
182
|
for (const t of e.tools || []) tools.add(t);
|
|
125
183
|
for (const a of e.with || []) withAgents.add(a);
|
|
126
184
|
for (const a of e.created || []) created.add(a);
|
|
127
185
|
if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
|
|
128
186
|
if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
|
|
187
|
+
if (e.kind === 'task.done' || e.kind === 'task.failed') {
|
|
188
|
+
const key = engineKey(e.engine);
|
|
189
|
+
if (key) {
|
|
190
|
+
const row = engines.get(key) || { engine: normalizeEngine(e.engine), tasks: 0, done: 0, failed: 0, tokens: 0, ratings: [] };
|
|
191
|
+
row.tasks += 1; row[e.kind === 'task.done' ? 'done' : 'failed'] += 1; row.tokens += e.size?.tokens || 0;
|
|
192
|
+
engines.set(key, row);
|
|
193
|
+
engineOfEntry.set(e.seq, key);
|
|
194
|
+
if (e.taskId) byTask.set(`${e.runId || ''}/${e.taskId}`, e.seq);
|
|
195
|
+
if (e.runId) byRun.set(e.runId, [...(byRun.get(e.runId) || []), e.seq]);
|
|
196
|
+
}
|
|
197
|
+
if (e.scm) { scm.tasks += 1; scm.commits += e.scm.commits || 0; if (e.scm.pr) scm.prs += 1; if (e.scm.merged) scm.merged += 1; }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const e of list) {
|
|
201
|
+
if (e.kind !== 'rating' || !e.rating) continue;
|
|
202
|
+
const seq = e.rating.about != null ? e.rating.about
|
|
203
|
+
: e.taskId && byTask.has(`${e.runId || ''}/${e.taskId}`) ? byTask.get(`${e.runId || ''}/${e.taskId}`)
|
|
204
|
+
: e.runId && (byRun.get(e.runId) || []).length === 1 ? byRun.get(e.runId)[0] : null;
|
|
205
|
+
const key = seq != null ? engineOfEntry.get(seq) : null;
|
|
206
|
+
if (key) engines.get(key).ratings.push(e.rating.score);
|
|
129
207
|
}
|
|
208
|
+
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
209
|
+
const byEngine = [...engines.values()].sort((a, b) => b.tasks - a.tasks).map((r) => ({
|
|
210
|
+
key: engineKey(r.engine), ...r.engine, tasks: r.tasks, done: r.done, failed: r.failed,
|
|
211
|
+
failRate: r.tasks ? Math.round((r.failed / r.tasks) * 1000) / 1000 : 0,
|
|
212
|
+
tokens: r.tasks ? Math.round(r.tokens / r.tasks) : 0, // mean per task — the cost proxy until the ledger prices it
|
|
213
|
+
rating: { avg: mean(r.ratings), count: r.ratings.length },
|
|
214
|
+
}));
|
|
215
|
+
// 1 − spread of rating across engines it was rated on (≥ 2): low spread = robust to routing;
|
|
216
|
+
// high spread = it NEEDS a particular engine, a fact a recruiter respects, not a penalty.
|
|
217
|
+
const ratedEngines = byEngine.filter((r) => r.rating.avg != null).map((r) => r.rating.avg);
|
|
218
|
+
const engineIndependence = ratedEngines.length >= 2 ? Math.round((1 - (Math.max(...ratedEngines) - Math.min(...ratedEngines))) * 1000) / 1000 : null;
|
|
219
|
+
// Leverage (rating above the engine's own mean) and efficiency (rating ÷ cost) wait on the
|
|
220
|
+
// model ledger (§13.2, with A1): they need every engine's mean, which one card cannot know.
|
|
130
221
|
const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
|
|
131
222
|
const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
|
|
132
223
|
const recentRatings = ratings.slice(-recent);
|
|
@@ -142,6 +233,9 @@ export function summarize(entries, { recent = 5 } = {}) {
|
|
|
142
233
|
created: [...created],
|
|
143
234
|
roles,
|
|
144
235
|
models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
|
|
236
|
+
byEngine,
|
|
237
|
+
engineIndependence,
|
|
238
|
+
scm,
|
|
145
239
|
rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
|
|
146
240
|
refs,
|
|
147
241
|
since: list[0]?.at || null,
|
|
@@ -156,7 +250,7 @@ export function summarize(entries, { recent = 5 } = {}) {
|
|
|
156
250
|
* summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
|
|
157
251
|
* without the tools cannot do the job), track record second, size third.
|
|
158
252
|
*/
|
|
159
|
-
export function fit(job, type, summary = null) {
|
|
253
|
+
export function fit(job, type, summary = null, { qualityOf = null, costOf = null, adjust = true } = {}) {
|
|
160
254
|
const needs = job?.needs || {};
|
|
161
255
|
const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
|
|
162
256
|
const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
|
|
@@ -174,11 +268,18 @@ export function fit(job, type, summary = null) {
|
|
|
174
268
|
const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
|
|
175
269
|
if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
|
|
176
270
|
let record = 0.5; // a fresh type is neither trusted nor distrusted
|
|
271
|
+
let adjusted = null;
|
|
177
272
|
if (summary && summary.entries) {
|
|
178
273
|
const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
|
|
179
|
-
|
|
274
|
+
// The track record uses the MODEL-ADJUSTED rating when the caller can say what the
|
|
275
|
+
// engines were worth (§13.3): an agent rated 0.82 mostly on a weak engine ranks above
|
|
276
|
+
// one rated 0.82 on a frontier model. The reasons say so, and a person can turn it off.
|
|
277
|
+
adjusted = adjust && qualityOf && summary.rating.avg != null ? adjustSummary(summary, { qualityOf, costOf: costOf || undefined }) : null;
|
|
278
|
+
const rated = summary.rating.avg == null ? 0.5 : (adjusted?.adjusted ?? summary.rating.avg);
|
|
180
279
|
record = doneRate * 0.5 + rated * 0.5;
|
|
181
|
-
reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? `, rated ${Math.round(summary.rating.avg * 100)}%` : ''}`);
|
|
280
|
+
reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? (adjusted && adjusted.adjusted !== adjusted.raw ? `, rated ${Math.round(adjusted.raw * 100)}% raw, ${Math.round(adjusted.adjusted * 100)}% adjusted — ${adjusted.basis[0] || 'engine-corrected'}` : `, rated ${Math.round(summary.rating.avg * 100)}%`) : ''}`);
|
|
281
|
+
if (adjusted?.leverage != null && adjusted.leverage > 0.05) reasons.push(`adds ${adjusted.leverage} over its engines' own quality`);
|
|
282
|
+
if (adjusted?.efficiency) reasons.push(`cleared the bar cheapest on ${adjusted.efficiency.engine}`);
|
|
182
283
|
if (summary.roles.orchestrator + summary.roles.manager + summary.roles['manager-of-managers'] > 0) reasons.push(`has led: ${summary.roles.orchestrator} as orchestrator, ${summary.roles.manager} as manager`);
|
|
183
284
|
} else {
|
|
184
285
|
reasons.push('no record yet');
|
|
@@ -187,5 +288,48 @@ export function fit(job, type, summary = null) {
|
|
|
187
288
|
const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
|
|
188
289
|
if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
|
|
189
290
|
const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
|
|
190
|
-
return { score, reasons, parts: { needs: needScore, record, size: sizeScore } };
|
|
291
|
+
return { score, reasons, parts: { needs: needScore, record, size: sizeScore }, ...(adjusted ? { adjusted } : {}) };
|
|
191
292
|
}
|
|
293
|
+
|
|
294
|
+
// ── Agent scores, normalised by engine (§13.3) — what the model ledger's cards make possible ──
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The model-adjusted view of an agent's card (scorecard.js `summarize()`), given what its
|
|
298
|
+
* engines are worth: `qualityOf(key)` → the engine's quality in [0, 1] (the card's mean
|
|
299
|
+
* rating for the job kind when observed, else the router's guess) or null when unknown.
|
|
300
|
+
*
|
|
301
|
+
* leverage rating on an engine minus that engine's quality, weighted by tasks — what
|
|
302
|
+
* the agent's prompt and tools add that the model does not supply on its own
|
|
303
|
+
* adjusted the raw rating corrected for the engines it ran on: work done on a weak
|
|
304
|
+
* engine counts for more, on a strong one for less; `k` bounds the correction
|
|
305
|
+
* efficiency adjusted rating ÷ cost per task on the cheapest engine that cleared `bar`
|
|
306
|
+
* (`costOf(key)` → $/task or a token proxy; null when nothing is priced)
|
|
307
|
+
*
|
|
308
|
+
* Returns `{ raw, adjusted, leverage, efficiency, basis[] }` with `basis` the reasons a
|
|
309
|
+
* person reads ("60 % of its tasks ran on a 0.3-quality engine").
|
|
310
|
+
*/
|
|
311
|
+
export function adjustSummary(summary, { qualityOf = () => null, costOf = () => null, reference = 0.6, k = 0.3, bar = 0.5 } = {}) {
|
|
312
|
+
const raw = summary?.rating?.avg ?? null;
|
|
313
|
+
const rows = (summary?.byEngine || []).filter((r) => r.key);
|
|
314
|
+
const known = rows.map((r) => ({ ...r, quality: qualityOf(r.key) })).filter((r) => Number.isFinite(r.quality));
|
|
315
|
+
const totalTasks = known.reduce((n, r) => n + r.tasks, 0);
|
|
316
|
+
const basis = [];
|
|
317
|
+
if (raw == null || !known.length || !totalTasks) return { raw, adjusted: raw, leverage: null, efficiency: null, basis: raw == null ? ['not rated yet'] : ['engines not rated yet — raw rating used'] };
|
|
318
|
+
// Correction: how far below the reference the engines it ran on sit, task-weighted.
|
|
319
|
+
const correction = k * known.reduce((s, r) => s + (r.tasks / totalTasks) * (reference - r.quality), 0);
|
|
320
|
+
const adjusted = Math.max(0, Math.min(1, raw + correction));
|
|
321
|
+
const weak = known.filter((r) => r.quality < reference);
|
|
322
|
+
if (weak.length) basis.push(`${Math.round((weak.reduce((n, r) => n + r.tasks, 0) / totalTasks) * 100)} % of its tasks ran on ${weak.length === 1 ? `a ${weak[0].quality}-quality engine` : 'engines below the reference'}`);
|
|
323
|
+
const strong = known.filter((r) => r.quality > reference);
|
|
324
|
+
if (strong.length && !weak.length) basis.push(`ran on engines above the reference (${strong.map((r) => r.quality).join(', ')})`);
|
|
325
|
+
// Leverage over the engines it was rated on.
|
|
326
|
+
const rated = known.filter((r) => r.rating?.avg != null);
|
|
327
|
+
const ratedTasks = rated.reduce((n, r) => n + r.rating.count, 0);
|
|
328
|
+
const leverage = ratedTasks ? r3(rated.reduce((s, r) => s + (r.rating.count / ratedTasks) * (r.rating.avg - r.quality), 0)) : null;
|
|
329
|
+
if (leverage != null) basis.push(`${leverage >= 0 ? '+' : ''}${leverage} over its engines' own quality`);
|
|
330
|
+
// Efficiency on the cheapest engine that cleared the bar.
|
|
331
|
+
const cleared = rated.filter((r) => r.rating.avg >= bar).map((r) => ({ ...r, cost: costOf(r.key) ?? (r.tokens || null) })).filter((r) => r.cost != null && r.cost > 0).sort((a, b) => a.cost - b.cost);
|
|
332
|
+
const efficiency = cleared.length ? { value: r3(adjusted / cleared[0].cost), engine: cleared[0].key, costPerTask: cleared[0].cost } : null;
|
|
333
|
+
return { raw: r3(raw), adjusted: r3(adjusted), leverage, efficiency, basis };
|
|
334
|
+
}
|
|
335
|
+
|
package/team-run.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// says so (`status: 'over-budget'`). Stop is one signal, fanned out.
|
|
18
18
|
|
|
19
19
|
import { normalizeTeam } from './team.js';
|
|
20
|
+
import { normalizeEngine, normalizeScm } from './scorecard.js';
|
|
20
21
|
import { createBudget } from './budget.js';
|
|
21
22
|
import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
|
|
22
23
|
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
|
|
@@ -79,9 +80,15 @@ export function dryRunTeam(team, request, { appoint = null } = {}) {
|
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* @param callModel `async ({ runId, taskId, role, model, mode, system, prompt, tools, signal, onDelta }) =>
|
|
82
|
-
* { ok, text, usage?, error?, aborted? }` — the host's model turn
|
|
83
|
+
* { ok, text, usage?, error?, aborted?, scm? }` — the host's model turn; `scm`
|
|
84
|
+
* is what a harness did in a git checkout (`{ repo, branch, head, headAfter,
|
|
85
|
+
* commits }`), when the bridge reported one
|
|
83
86
|
* @param toolsFor `(role) => toolset | undefined` — narrowed to the role's grants by the host
|
|
84
|
-
* @param appoint `(role) => { model, mode } | null` — the host's
|
|
87
|
+
* @param appoint `(role) => { model, mode, engine?, reasons?, alternatives? } | null` — the host's
|
|
88
|
+
* roster through cowriter-router. `engine` (`{ kind: 'model'|'harness', id,
|
|
89
|
+
* model? }`) says WHAT the model id is, so the record can split by it;
|
|
90
|
+
* `reasons` and `alternatives` are why this one and who else could have —
|
|
91
|
+
* said as `task.routed`, which every run records from here on (pillars §13)
|
|
85
92
|
* @param runRecipe `async (name, params) => result` for `mode: 'recipe'` roles (optional)
|
|
86
93
|
* @param emit `(type, payload)` — run.started · plan.ready · task.started · task.finding ·
|
|
87
94
|
* task.done · task.failed · run.merging · run.done; the host forwards them to
|
|
@@ -129,6 +136,21 @@ export async function runTeam({
|
|
|
129
136
|
return (appoint ? appoint(r, { exclude }) : null) || (r.model && !exclude?.has(r.model) ? { model: r.model, mode: r.mode } : null);
|
|
130
137
|
};
|
|
131
138
|
const MAX_APPOINTMENTS = 3;
|
|
139
|
+
// The routing decision, on the record: which engine, why, who else could have. A host that
|
|
140
|
+
// does not say the kind gets `model` — the honest default for a bare id; the pilot's hosts
|
|
141
|
+
// both say. Exploration (a tier cheaper on purpose) is the project loop's, later; false here.
|
|
142
|
+
const routeOf = (m, role, { attempt = 1, exclude = null, handoff = null } = {}) => {
|
|
143
|
+
const reasons = Array.isArray(m.reasons) ? m.reasons.map(String) : [];
|
|
144
|
+
if (handoff) reasons.unshift(`handed off by ${handoff.by}${handoff.reason ? ` — ${handoff.reason}` : ''}`);
|
|
145
|
+
else if (!reasons.length && role.model && m.model === role.model) reasons.push('pinned by the role');
|
|
146
|
+
if (attempt > 1 && exclude?.size) reasons.push(`after ${[...exclude].join(', ')} (unavailable)`);
|
|
147
|
+
return {
|
|
148
|
+
engine: normalizeEngine(m.engine || { id: m.model }),
|
|
149
|
+
reasons,
|
|
150
|
+
alternatives: (Array.isArray(m.alternatives) ? m.alternatives : []).slice(0, 5).map((a) => normalizeEngine(a)).filter(Boolean),
|
|
151
|
+
exploration: false,
|
|
152
|
+
};
|
|
153
|
+
};
|
|
132
154
|
// A model that was not there for one member is not there for the next: what failed as
|
|
133
155
|
// unavailable anywhere in this run is skipped by every later appointment. Two members
|
|
134
156
|
// each spent two minutes finding out the same agent was down.
|
|
@@ -199,6 +221,8 @@ export async function runTeam({
|
|
|
199
221
|
// the record. Only a task that has never been attempted starts from the bare prompt.
|
|
200
222
|
let transcript = was ? [...was.transcript] : [];
|
|
201
223
|
const attempts = was?.attempts ? [...was.attempts] : [];
|
|
224
|
+
let routed = null; // the last routing decision, on the task row
|
|
225
|
+
let scm = null; // what the last attempt did in a checkout
|
|
202
226
|
// The task's own abort: an ask nobody answered in time stops THIS member's turn (the
|
|
203
227
|
// run then checkpoints), without stopping the run's other members. A person's hand-off
|
|
204
228
|
// aborts it too, and names where the task continues.
|
|
@@ -250,6 +274,7 @@ export async function runTeam({
|
|
|
250
274
|
let lastModel = was?.attempts?.at?.(-1)?.model || null;
|
|
251
275
|
for (let attempt = 1; ; attempt++) {
|
|
252
276
|
let m;
|
|
277
|
+
const handoffNow = handoffTo;
|
|
253
278
|
if (handoffTo) {
|
|
254
279
|
// A person's hand-off names the model; the task continues there whatever the
|
|
255
280
|
// roster would have chosen. Said on the board, so everyone knows who has it.
|
|
@@ -267,7 +292,9 @@ export async function runTeam({
|
|
|
267
292
|
if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
|
|
268
293
|
// Who is doing this task, for a ledger that shows the lanes — said per attempt.
|
|
269
294
|
say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
|
|
270
|
-
|
|
295
|
+
routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
|
|
296
|
+
say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
|
|
297
|
+
attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
|
|
271
298
|
const sent = messagesFor({ transcript }, { prompt, note });
|
|
272
299
|
// The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
|
|
273
300
|
// moment it exists (a tool call, its result) puts it on the record then, so a
|
|
@@ -283,6 +310,8 @@ export async function runTeam({
|
|
|
283
310
|
});
|
|
284
311
|
usage = res?.usage || null;
|
|
285
312
|
if (usage) budget.charge(usage);
|
|
313
|
+
// What the attempt did in a checkout, when the host's harness reported one (§14).
|
|
314
|
+
if (normalizeScm(res?.scm)) { scm = normalizeScm(res.scm); say('task.scm', { taskId: task.id, role: role.id, ...scm }); }
|
|
286
315
|
// Whatever the attempt did is the task's now — on the record, before any verdict.
|
|
287
316
|
// What the host already reported step by step is not reported again.
|
|
288
317
|
transcript = mergeTranscript(sent, res);
|
|
@@ -327,14 +356,14 @@ export async function runTeam({
|
|
|
327
356
|
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
|
|
328
357
|
const thread = board.threadForTask(task.id);
|
|
329
358
|
if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
|
|
330
|
-
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
359
|
+
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
331
360
|
tasksOut.push(row);
|
|
332
361
|
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
|
|
333
362
|
// The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
|
|
334
363
|
// in which role — produced here, attested by the store, never written by the agent.
|
|
335
364
|
if (status === 'ok' || status === 'failed') {
|
|
336
365
|
say('task.scored', {
|
|
337
|
-
agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), outcome: status === 'ok' ? 'task.done' : 'task.failed',
|
|
366
|
+
agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), engine: routed?.engine || null, scm: scm || undefined, outcome: status === 'ok' ? 'task.done' : 'task.failed',
|
|
338
367
|
size: { ms: row.ms, steps: (row.transcript || []).length, tools: (row.transcript || []).filter((m) => m.role === 'tool').length, findings: findings.length, tokens: usage ? Number(usage.input_tokens || usage.prompt_tokens || 0) + Number(usage.output_tokens || usage.completion_tokens || 0) : 0 },
|
|
339
368
|
roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
|
|
340
369
|
refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
|
|
@@ -383,11 +412,15 @@ export async function runTeam({
|
|
|
383
412
|
let res = null;
|
|
384
413
|
const excl = new Set();
|
|
385
414
|
let judgeErr = '';
|
|
415
|
+
let judgeModel = null; // the appointment that answered (or the last one tried)
|
|
416
|
+
let judgeRoute = null;
|
|
386
417
|
for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
|
|
387
418
|
const mm = attempt === 1 ? (runExclude.has(m?.model) ? modelFor(judge, excluding(excl)) : m) : modelFor(judge, excluding(excl));
|
|
388
419
|
if (!mm?.model) break;
|
|
389
420
|
if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl], error: judgeErr });
|
|
390
421
|
say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
|
|
422
|
+
judgeModel = mm; judgeRoute = routeOf(mm, judge, { attempt, exclude: excl });
|
|
423
|
+
say('task.routed', { taskId: 'merge', role: judge.id, attempt, ...judgeRoute });
|
|
391
424
|
res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: mm.model, mode: 'model', system: judge.prompt, prompt, tools: judgeTools, signal, onDelta: (delta, full) => say('task.delta', { taskId: 'merge', role: judge.id, delta, text: full }) });
|
|
392
425
|
if (res?.usage) budget.charge(res.usage);
|
|
393
426
|
if (res?.ok && String(res.text || '').trim()) break;
|
|
@@ -397,7 +430,9 @@ export async function runTeam({
|
|
|
397
430
|
}
|
|
398
431
|
const judged = res?.ok && String(res.text || '').trim();
|
|
399
432
|
say(judged ? 'task.done' : 'task.failed', { taskId: 'merge', role: judge.id, status: judged ? 'ok' : 'failed', error: judged ? null : (res?.error || 'the judge did not answer'), findings: 0 });
|
|
400
|
-
|
|
433
|
+
const judgeScm = normalizeScm(res?.scm);
|
|
434
|
+
if (judgeScm) say('task.scm', { taskId: 'merge', role: judge.id, ...judgeScm });
|
|
435
|
+
say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: judgeModel?.model || m?.model, engine: judgeRoute?.engine || null, scm: judgeScm || undefined, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
|
|
401
436
|
say('run.usage', { usage: budget.snapshot() });
|
|
402
437
|
proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
|
|
403
438
|
} else {
|
package/team-tool.js
CHANGED
|
@@ -36,7 +36,7 @@ export function teamToolSpec(teams) {
|
|
|
36
36
|
+ '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
|
|
37
37
|
+ '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
|
|
38
38
|
+ 'A team: {"name":"research" (a short identifier: letters, digits, - _; used as /research),"description":"…","roles":[{"id":"researcher","prompt":"…","prefer":"balanced","grants":["data","web"]},{"id":"writer","prompt":"…","prefer":"strong","grants":["none"]}],"merge":"judge","judge":"writer","budget":{"tokens":40000,"ms":300000}}. '
|
|
39
|
-
+ 'grants: none | data | web | history | mcp | mcp:<server
|
|
39
|
+
+ 'grants: none | data | web | history | mcp | mcp:<server> | shell | fs:write | scm:read | scm:push | scm:pr. A role may say "agent":"<id>" instead of a prompt to stand for an agent from the pool. merge: judge | converge | concat | first. A budget is required. '
|
|
40
40
|
+ 'Order the work with "dependsOn": a role that builds on another\'s findings (a budget checker on a researcher) lists it, so it runs after and reads the board instead of searching again. The judge does not need a task of its own - the merge is its work.',
|
|
41
41
|
parameters: {
|
|
42
42
|
type: 'object',
|
|
@@ -70,8 +70,16 @@ const json = (v) => JSON.stringify(v);
|
|
|
70
70
|
* @param confirmSave `async (detail, team) => 'allow' | 'deny'`; absent = save refused
|
|
71
71
|
* @param saveTeam `async (team) => void`
|
|
72
72
|
*/
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* `resolve` is the host's `(team) => team` that fills roles standing for agents from the
|
|
75
|
+
* pool (agent.js resolveTeam) — applied before a dry run and before a run, never to what is
|
|
76
|
+
* saved: the stored team keeps its references, the run gets the cards as they are now.
|
|
77
|
+
*/
|
|
78
|
+
export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null } = {}) {
|
|
74
79
|
const byName = new Map(usable(teams).map((t) => [t.name, t]));
|
|
80
|
+
// A team whose roles stand for agents is filled from the pool on the way to a run; a
|
|
81
|
+
// resolver that throws (an agent missing from the pool) is the tool's error, not a crash.
|
|
82
|
+
const resolved = (t) => (typeof resolve === 'function' ? resolve(t) : t);
|
|
75
83
|
let bound = null;
|
|
76
84
|
// One run per team+request per turn. A run that failed, answered with nothing, or ran
|
|
77
85
|
// out of budget comes back as a result the model must REPORT — asking for it again in the
|
|
@@ -97,7 +105,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
97
105
|
if (byName.has(team.name)) return json({ error: `A team named "${team.name}" already exists. Pick another name.` });
|
|
98
106
|
if (!confirmSave || !saveTeam) return json({ error: 'Saving a team needs the user\'s approval, which this surface cannot ask for. Describe the team and suggest saving it from the side panel or the desktop.' });
|
|
99
107
|
const norm = normalizeTeam(team);
|
|
100
|
-
const dry = dryRunTeam(norm, '', { appoint });
|
|
108
|
+
const dry = dryRunTeam(resolved(norm), '', { appoint });
|
|
101
109
|
const decision = await confirmSave(describeTeamForApproval(norm, dry), norm);
|
|
102
110
|
if (decision !== 'allow') return json({ error: `The user did not save "${norm.name}". Do not propose it again this turn.`, declined: true });
|
|
103
111
|
const stored = { ...norm, createdAt: Date.now() };
|
|
@@ -111,7 +119,8 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
111
119
|
const request = String(input?.request || '').trim();
|
|
112
120
|
|
|
113
121
|
if (action === 'dry_run') {
|
|
114
|
-
|
|
122
|
+
let dry;
|
|
123
|
+
try { dry = dryRunTeam(resolved(team), request, { appoint }); } catch (e) { return json({ error: e?.message || String(e) }); }
|
|
115
124
|
return json({ name: team.name, ok: dry.ok, missing: dry.missing, roles: dry.roles, plan: dry.plan, tasks: dry.tasks, merge: dry.merge, budget: dry.budget });
|
|
116
125
|
}
|
|
117
126
|
if (action === 'run') {
|
|
@@ -123,9 +132,10 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
123
132
|
const key = team.name;
|
|
124
133
|
const prior = ran.get(key);
|
|
125
134
|
if (prior) return json({ error: `The "${team.name}" team already ran in this turn (run ${prior.runId}, ${prior.status}). Do not run it again, even with a different request: report what it produced — ${prior.summary} — with its proposal, and ask the user how to proceed.`, runId: prior.runId, status: prior.status, tasks: prior.tasks, proposal: prior.proposal });
|
|
126
|
-
|
|
135
|
+
let dry;
|
|
136
|
+
try { dry = dryRunTeam(resolved(team), request, { appoint }); } catch (e) { return json({ error: e?.message || String(e) }); }
|
|
127
137
|
if (!dry.ok) return json({ error: `No model is available for role(s): ${dry.missing.join(', ')}.`, roles: dry.roles });
|
|
128
|
-
const result = await run({ team, request, toolset: bound });
|
|
138
|
+
const result = await run({ team: resolved(team), request, toolset: bound });
|
|
129
139
|
const findings = (result.board || []).map((f) => ({ role: f.role, kind: f.kind, text: f.text, refs: f.refs }));
|
|
130
140
|
const tasks = (result.tasks || []).map((x) => ({ id: x.id, role: x.role, status: x.status, ms: x.ms, findings: (x.findings || []).length, error: x.error || undefined }));
|
|
131
141
|
const failed = tasks.filter((x) => x.status !== 'ok');
|
package/team-trail.js
CHANGED
|
@@ -19,6 +19,10 @@ export function teamLine(ev) {
|
|
|
19
19
|
case 'task.handoff': return { type: 'status', text: `${role} handed off ${ev.from ? `from ${ev.from} ` : ''}to ${ev.to} by ${ev.by || 'person'}${ev.reason ? ` — ${ev.reason}` : ''}` };
|
|
20
20
|
case 'task.step': return null;
|
|
21
21
|
case 'task.scored': return null;
|
|
22
|
+
// The route is the lane's business (task.model already names it); the reasons are a line
|
|
23
|
+
// only when there are any — a re-appointment says its own.
|
|
24
|
+
case 'task.routed': return ev.reasons?.length && ev.attempt === 1 ? { type: 'status', text: `${role} → ${ev.engine?.id || '?'}${ev.engine?.model ? `/${ev.engine.model}` : ''} (${ev.reasons.join('; ')})` } : null;
|
|
25
|
+
case 'task.scm': return ev.commits ? { type: 'status', text: `${role} committed ${ev.commits} on ${ev.branch || 'a branch'}${ev.headAfter ? ` @ ${String(ev.headAfter).slice(0, 7)}` : ''}` } : null;
|
|
22
26
|
case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
|
|
23
27
|
case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
|
|
24
28
|
case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
|
|
@@ -40,6 +44,8 @@ export function teamLanes(prev, ev) {
|
|
|
40
44
|
case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
|
|
41
45
|
case 'task.finding': lanes.findings += 1; if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], findings: (lanes.tasks[ev.taskId].findings || 0) + 1 }; break;
|
|
42
46
|
case 'task.model': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.model }; break;
|
|
47
|
+
case 'task.routed': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], engine: ev.engine || null }; break;
|
|
48
|
+
case 'task.scm': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], scm: { branch: ev.branch, commits: ev.commits || 0, head: ev.headAfter || ev.head } }; break;
|
|
43
49
|
case 'task.handoff': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.to, handoffs: (lanes.tasks[ev.taskId].handoffs || 0) + 1 }; break;
|
|
44
50
|
case 'task.step': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], steps: (lanes.tasks[ev.taskId].steps || 0) + (ev.steps || []).length }; break;
|
|
45
51
|
case 'task.tool': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], tools: (lanes.tasks[ev.taskId].tools || 0) + 1, lastTool: ev.text ? `${ev.name} ${ev.text}` : ev.name }; break;
|