crewforth 3.1.0-rc.5 → 3.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -12
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +41 -21
- package/kit/CLAUDE.md +9 -7
- package/kit/agents/crew-backend-expert.md +4 -1
- package/kit/agents/crew-commit-agent.md +4 -1
- package/kit/agents/crew-database-expert.md +4 -1
- package/kit/agents/crew-devops-expert.md +4 -1
- package/kit/agents/crew-frontend-expert.md +4 -1
- package/kit/agents/crew-performance-expert.md +4 -1
- package/kit/agents/crew-planner.md +10 -1
- package/kit/agents/crew-privacy-agent.md +4 -1
- package/kit/agents/crew-review-agent.md +4 -1
- package/kit/agents/crew-security-expert.md +4 -1
- package/kit/agents/crew-session-manager.md +4 -1
- package/kit/agents/crew-test-expert.md +4 -1
- package/kit/eval/smoke-test.sh +744 -18
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +487 -60
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +43 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +85 -6
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +45 -7
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +8 -1
- package/kit/studio/web/app.js +203 -32
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +91 -42
- package/kit/studio/web/chat.js +11 -1
- package/kit/studio/web/convo.js +9 -0
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +29 -9
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +27 -5
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +30 -0
- package/kit/studio/web/style.css +85 -9
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +26 -7
- package/package.json +1 -1
|
@@ -17,18 +17,20 @@ import os from 'node:os';
|
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
18
|
|
|
19
19
|
import { getFleet, measureSpawnCost } from './lib/fleet.js';
|
|
20
|
-
import { projectDir, listSessions, findSession, listProjects, sessionCwd } from './lib/projects.js';
|
|
20
|
+
import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentMetaFiles } from './lib/projects.js';
|
|
21
|
+
import { agentOrigins } from './lib/agent-origin.js';
|
|
21
22
|
import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
|
|
22
23
|
import { cachedUsage } from './lib/usage.js';
|
|
24
|
+
import { readModels, readRecord, readCosts } from './lib/model-outcomes.js';
|
|
23
25
|
|
|
24
26
|
const USAGE_BATCH = 12;
|
|
25
27
|
import { palette } from './lib/palette.js';
|
|
26
28
|
import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
|
|
27
29
|
import { parsePeers, askAll, ask } from './lib/peers.js';
|
|
28
30
|
import {
|
|
29
|
-
createSession, getSession, listSessionsOwned, reap, stopAll, ALLOWED_MODES,
|
|
31
|
+
createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
|
|
30
32
|
} from './lib/session.js';
|
|
31
|
-
import { decide, pending, alwaysList, revoke } from './lib/permissions.js';
|
|
33
|
+
import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
|
|
32
34
|
import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
|
|
33
35
|
import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
|
|
34
36
|
import { writeState, clearStateSync, findRunning } from './lib/instance.js';
|
|
@@ -138,8 +140,45 @@ export function writeAllowed(req) {
|
|
|
138
140
|
return { ok: true };
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The record of outcomes for a project, with each agent's cost where it can be read. Only the sessions written
|
|
145
|
+
* since the record began are opened, and each is read once and kept until it grows (usage.js).
|
|
146
|
+
*/
|
|
147
|
+
/** The ids of the agents of one session, from the files it left: the only lines of the record a page of it needs. */
|
|
148
|
+
async function agentIdsOf(session) {
|
|
149
|
+
if (!session?.subagentsDir) return [];
|
|
150
|
+
return (await agentMetaFiles(session.subagentsDir)).map((m) => path.basename(m).replace(/^agent-/, '').replace(/\.meta\.json$/, ''));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What the classes of a project cost: each agent's tokens at list price, from the transcripts of the project's
|
|
155
|
+
* sessions that are on this machine. Reading those is the expensive part of the Models view, so it is asked for
|
|
156
|
+
* apart from the record and less often, and a session whose transcript has not changed is not read again
|
|
157
|
+
* (`cachedUsage`).
|
|
158
|
+
*/
|
|
159
|
+
async function modelCosts(cwd) {
|
|
160
|
+
const rec = readRecord(cwd);
|
|
161
|
+
if (!rec.measured) return { measured: false, reason: rec.reason, costs: {}, sessionsRead: 0 };
|
|
162
|
+
const per = new Map();
|
|
163
|
+
const dir = await projectDir(cwd);
|
|
164
|
+
let read = 0;
|
|
165
|
+
if (dir) {
|
|
166
|
+
const since = (rec.firstAt ?? 0) - 3600_000;
|
|
167
|
+
for (const s of (await listSessions(dir)).filter((x) => x.modifiedAt >= since).slice(0, MODELS_SESSIONS_MAX)) {
|
|
168
|
+
const u = await cachedUsage(s);
|
|
169
|
+
read += 1;
|
|
170
|
+
for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a.cost);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { measured: true, costs: readCosts(cwd, (id) => (per.has(id) ? per.get(id) : null)), sessionsRead: read };
|
|
174
|
+
}
|
|
175
|
+
const MODELS_SESSIONS_MAX = 60;
|
|
176
|
+
|
|
141
177
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
142
178
|
|
|
179
|
+
/** The session events after which a subscriber is sent the session's summary again. */
|
|
180
|
+
export const STATE_AFTER = new Set(['result', 'exit', 'mode']);
|
|
181
|
+
|
|
143
182
|
function readBody(req) {
|
|
144
183
|
return new Promise((resolve) => {
|
|
145
184
|
let size = 0;
|
|
@@ -294,6 +333,24 @@ async function handle(req, res) {
|
|
|
294
333
|
}
|
|
295
334
|
|
|
296
335
|
// What Crewforth already measures about itself, reported as Crewforth reports it.
|
|
336
|
+
// Which model did which class of work in this project, and how it went: Crewforth's own record of outcomes,
|
|
337
|
+
// and the lines of it that belong to the session on screen. What the classes cost is the next route.
|
|
338
|
+
if (url.pathname === '/api/models') {
|
|
339
|
+
const sid = url.searchParams.get('session');
|
|
340
|
+
const session = sid ? await findSession(sid) : null;
|
|
341
|
+
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
342
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], byAgent: {} });
|
|
343
|
+
return sendJson(res, 200, readModels(cwd, { agents: await agentIdsOf(session) }));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (url.pathname === '/api/models/cost') {
|
|
347
|
+
const sid = url.searchParams.get('session');
|
|
348
|
+
const session = sid ? await findSession(sid) : null;
|
|
349
|
+
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
350
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, sessionsRead: 0 });
|
|
351
|
+
return sendJson(res, 200, await modelCosts(cwd));
|
|
352
|
+
}
|
|
353
|
+
|
|
297
354
|
if (url.pathname === '/api/kit') {
|
|
298
355
|
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
299
356
|
const sid = url.searchParams.get('session');
|
|
@@ -310,6 +367,14 @@ async function handle(req, res) {
|
|
|
310
367
|
return sendJson(res, 200, palette());
|
|
311
368
|
}
|
|
312
369
|
|
|
370
|
+
// Where the agents of a session's project are defined: the project's own files and the user's.
|
|
371
|
+
if (url.pathname === '/api/agents') {
|
|
372
|
+
const sid = url.searchParams.get('session');
|
|
373
|
+
const session = sid ? await findSession(sid) : null;
|
|
374
|
+
const cwd = session ? await sessionCwd(session.file, session.bytes) : null;
|
|
375
|
+
return sendJson(res, 200, { measured: true, ...agentOrigins(cwd) });
|
|
376
|
+
}
|
|
377
|
+
|
|
313
378
|
if (url.pathname === '/api/sessions') {
|
|
314
379
|
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
315
380
|
const dir = await projectDir(cwd);
|
|
@@ -455,12 +520,12 @@ async function handle(req, res) {
|
|
|
455
520
|
const body = await readBody(req);
|
|
456
521
|
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
457
522
|
const toolUseId = decodeURIComponent(permMatch[2]);
|
|
458
|
-
const out = decide(s.id, toolUseId, body.verdict);
|
|
523
|
+
const out = decide(s.id, toolUseId, body.verdict, { answers: body.answers, mode: body.mode });
|
|
459
524
|
if (out.ok) s.noteDecision(toolUseId, body.verdict);
|
|
460
525
|
return sendJson(res, out.ok ? 200 : 400, out);
|
|
461
526
|
}
|
|
462
527
|
|
|
463
|
-
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events))?$/);
|
|
528
|
+
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
|
|
464
529
|
if (ownedMatch) {
|
|
465
530
|
const s = getSession(decodeURIComponent(ownedMatch[1]));
|
|
466
531
|
if (!s) return sendJson(res, 404, { ok: false, reason: 'no such owned session' });
|
|
@@ -476,6 +541,13 @@ async function handle(req, res) {
|
|
|
476
541
|
|
|
477
542
|
if (verb === 'stop') return sendJson(res, 200, { ...s.stop(), session: s.summary() });
|
|
478
543
|
|
|
544
|
+
// The viewer chose another mode for the session, from the three a session may be started in.
|
|
545
|
+
if (verb === 'mode') {
|
|
546
|
+
const asked = await readBody(req);
|
|
547
|
+
const out = s.requestMode(asked?.mode);
|
|
548
|
+
return sendJson(res, out.ok ? 200 : 400, { ...out, session: s.summary() });
|
|
549
|
+
}
|
|
550
|
+
|
|
479
551
|
const body = await readBody(req);
|
|
480
552
|
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
481
553
|
const sent = s.send(body.text);
|
|
@@ -728,7 +800,9 @@ function ownedStream(req, res, url, session) {
|
|
|
728
800
|
|
|
729
801
|
const unsubscribe = session.subscribe((ev) => {
|
|
730
802
|
write('event', ev);
|
|
731
|
-
|
|
803
|
+
// The summary is what a pane paints its strip from. It follows what changes it: a turn ending, the session
|
|
804
|
+
// going, and the mode Claude Code reports. Without the last, the strip waited for the beat below.
|
|
805
|
+
if (STATE_AFTER.has(ev.rec?.type)) write('state', session.summary());
|
|
732
806
|
}, after);
|
|
733
807
|
|
|
734
808
|
const beat = setInterval(() => write('state', session.summary()), 15000);
|
|
@@ -815,6 +889,10 @@ async function main() {
|
|
|
815
889
|
process.exit(await selftest());
|
|
816
890
|
}
|
|
817
891
|
|
|
892
|
+
// What an earlier server left under the temp directory when it was killed: gone before this one makes its own.
|
|
893
|
+
// Only a server that is about to run does this; asking for --help or --selftest removes nothing.
|
|
894
|
+
sweepLeft();
|
|
895
|
+
|
|
818
896
|
const server = http.createServer((req, res) => {
|
|
819
897
|
handle(req, res).catch((e) => {
|
|
820
898
|
process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
|
|
@@ -900,6 +978,7 @@ async function main() {
|
|
|
900
978
|
// Children outlive their parent unless told otherwise, and a panel that
|
|
901
979
|
// leaks running sessions is worse than one that never started them.
|
|
902
980
|
stopAll();
|
|
981
|
+
dropSpools();
|
|
903
982
|
server.close(() => process.exit(0));
|
|
904
983
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
905
984
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Where a Claude Code agent is defined, as far as that can be read from disk.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code takes an agent that is not built in from a Markdown file whose frontmatter names it: in the project
|
|
4
|
+
// (`.claude/agents/`), in the user's own directory (`~/.claude/agents/`), or in a plugin. This file reads the first
|
|
5
|
+
// two and nothing else. It keeps no list of built-in types: an agent found in neither place is reported as not
|
|
6
|
+
// found, which is all that is known about it.
|
|
7
|
+
//
|
|
8
|
+
// Not read: a plugin's files (a plugin agent is told by its name, `plugin:agent`, in web/names.js), agents given
|
|
9
|
+
// on the command line, managed settings, and files in a directory under `agents/`.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { PROJECTS_ROOT } from './projects.js';
|
|
14
|
+
|
|
15
|
+
/** The agents one directory defines: { name: file name }. Null when the directory is not there or cannot be read. */
|
|
16
|
+
export function agentsIn(dir) {
|
|
17
|
+
let files;
|
|
18
|
+
try { files = fs.readdirSync(dir); } catch { return null; }
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const f of files.sort()) {
|
|
21
|
+
if (!f.endsWith('.md')) continue;
|
|
22
|
+
let src;
|
|
23
|
+
try { src = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
24
|
+
// Frontmatter only, as the palette reads it: a "name:" in the body is not the agent's name.
|
|
25
|
+
const fm = src.replace(/\r\n/g, '\n').split(/^---$/m)[1] ?? '';
|
|
26
|
+
const name = fm.match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
|
|
27
|
+
if (name && out[name] === undefined) out[name] = f;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Claude Code's own directory is the parent of its projects directory; the user's agents sit beside the projects. */
|
|
33
|
+
export const USER_AGENTS_DIR = path.join(path.dirname(PROJECTS_ROOT), 'agents');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @returns { project, user } — each { name: file name }, or null where that directory was not read. A project's
|
|
37
|
+
* definition is the one Claude Code uses when both name the same agent, and it is listed in both here.
|
|
38
|
+
*/
|
|
39
|
+
export function agentOrigins(cwd, userDir = USER_AGENTS_DIR) {
|
|
40
|
+
return {
|
|
41
|
+
project: cwd ? agentsIn(path.join(cwd, '.claude', 'agents')) : null,
|
|
42
|
+
user: agentsIn(userDir),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -85,6 +85,41 @@ export function modelRank(model) {
|
|
|
85
85
|
return i === -1 ? null : i;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// The tool a subagent hands its result back with. The report is the call's `message`; the agent often says
|
|
89
|
+
// nothing after it, so a transcript with a handback and no closing text still holds a report.
|
|
90
|
+
const HANDBACK = 'SubagentHandback';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What an agent reported, from its own transcript, and where that was read from.
|
|
94
|
+
*
|
|
95
|
+
* `closing` — text the agent wrote after its last handback: its own last word, and it wins;
|
|
96
|
+
* `handback` — the message of its last handback call, when nothing was written after it;
|
|
97
|
+
* `text` — with no handback at all, the last text it wrote (what came before is narration).
|
|
98
|
+
*
|
|
99
|
+
* Text written BEFORE a handback is narration on the way to it and is never the report.
|
|
100
|
+
* @returns { text, from } — text is null when the transcript holds none of the three
|
|
101
|
+
*/
|
|
102
|
+
export function reportIn(records) {
|
|
103
|
+
let lastText = null; // the last text block of all
|
|
104
|
+
let afterHandback = null; // the last text block since the last handback
|
|
105
|
+
let handback = null;
|
|
106
|
+
for (const r of records ?? []) {
|
|
107
|
+
if (r?.type !== 'assistant' || !Array.isArray(r.message?.content)) continue;
|
|
108
|
+
for (const c of r.message.content) {
|
|
109
|
+
if (c?.type === 'text' && typeof c.text === 'string' && c.text.trim()) {
|
|
110
|
+
lastText = c.text;
|
|
111
|
+
if (handback !== null) afterHandback = c.text;
|
|
112
|
+
} else if (c?.type === 'tool_use' && c.name === HANDBACK && typeof c.input?.message === 'string' && c.input.message.trim()) {
|
|
113
|
+
handback = c.input.message;
|
|
114
|
+
afterHandback = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (afterHandback !== null) return { text: afterHandback, from: 'closing' };
|
|
119
|
+
if (handback !== null) return { text: handback, from: 'handback' };
|
|
120
|
+
return { text: lastText, from: lastText === null ? null : 'text' };
|
|
121
|
+
}
|
|
122
|
+
|
|
88
123
|
/** How a report says it ended: the `confidence: high|low` line a crew agent closes with, or null. */
|
|
89
124
|
export function confidenceOf(text) {
|
|
90
125
|
const m = typeof text === 'string' ? text.trimEnd().match(/(?:^|\n)\W*confidence:\s*(high|low)\W*$/i) : null;
|
|
@@ -287,7 +322,7 @@ async function scanAgent(file, usage = null, agentId = null) {
|
|
|
287
322
|
const stats = {
|
|
288
323
|
tools: {}, toolCount: 0, lastTool: null, errors: 0,
|
|
289
324
|
model: null, // the model its own transcript names: what it actually ran on
|
|
290
|
-
lastText: null, // the end of
|
|
325
|
+
lastText: null, // the end of its report, for the line a report closes with
|
|
291
326
|
tokens: null, startedAt: null, endedAt: null, turns: 0,
|
|
292
327
|
owns: [], // tool_use ids this agent emitted — how nesting is resolved
|
|
293
328
|
};
|
|
@@ -304,8 +339,6 @@ async function scanAgent(file, usage = null, agentId = null) {
|
|
|
304
339
|
stats.turns += 1;
|
|
305
340
|
const mdl = r.message?.model;
|
|
306
341
|
if (typeof mdl === 'string' && mdl && mdl !== '<synthetic>') stats.model = mdl;
|
|
307
|
-
const said = textOf(r.message?.content);
|
|
308
|
-
if (said.trim()) stats.lastText = said.slice(-400);
|
|
309
342
|
const u = r.message?.usage;
|
|
310
343
|
if (u && u.cache_read_input_tokens != null) {
|
|
311
344
|
stats.tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
@@ -333,6 +366,8 @@ async function scanAgent(file, usage = null, agentId = null) {
|
|
|
333
366
|
stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
|
|
334
367
|
? stats.endedAt - stats.startedAt
|
|
335
368
|
: null;
|
|
369
|
+
// The report, wherever the agent put it: the confidence line is its last line, handback included.
|
|
370
|
+
stats.lastText = reportIn(records).text?.slice(-400) ?? null;
|
|
336
371
|
stats.midTurn = midTurn(records);
|
|
337
372
|
stats.open = openCalls(records);
|
|
338
373
|
return stats;
|
|
@@ -692,9 +727,10 @@ export async function agentDetail(session, agentId) {
|
|
|
692
727
|
}
|
|
693
728
|
}
|
|
694
729
|
|
|
695
|
-
// The report
|
|
696
|
-
// narration between tool calls, which is progress rather than conclusion.
|
|
697
|
-
const
|
|
730
|
+
// The report: what the agent wrote last, or what it handed back when it wrote nothing after that. Earlier
|
|
731
|
+
// blocks are narration between tool calls, which is progress rather than conclusion.
|
|
732
|
+
const reported = reportIn(records);
|
|
733
|
+
const report = reported.text;
|
|
698
734
|
|
|
699
735
|
return {
|
|
700
736
|
agentId,
|
|
@@ -705,9 +741,11 @@ export async function agentDetail(session, agentId) {
|
|
|
705
741
|
spawnDepth: meta.spawnDepth ?? null,
|
|
706
742
|
prompt,
|
|
707
743
|
report,
|
|
744
|
+
// Where the report was read from: 'closing', 'handback' or 'text' (see reportIn); null when there is none.
|
|
745
|
+
reportFrom: reported.from,
|
|
708
746
|
// Kept apart so the UI can show progress for an agent that has not
|
|
709
747
|
// reported yet, without pretending the narration is a conclusion.
|
|
710
|
-
narration: texts.
|
|
748
|
+
narration: texts.filter((t) => t.text !== report).map((t) => t.text),
|
|
711
749
|
thinking: lastThinking,
|
|
712
750
|
timeline,
|
|
713
751
|
lastError,
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// What Crewforth recorded about which model did which work, and how it went.
|
|
2
|
+
//
|
|
3
|
+
// Crewforth writes one line per finished crew agent to `.claude/state/model-outcomes.tsv` (hooks/agent-outcome.sh):
|
|
4
|
+
//
|
|
5
|
+
// ts agent_id agent change risk card model verify fixes escalated_from ran_on confidence
|
|
6
|
+
//
|
|
7
|
+
// Twelve columns, tab-separated, a header on the first line, "-" for an empty value, and any column added later
|
|
8
|
+
// is added at the end. This file reads that and adds nothing to it: a class's first-try success is counted from
|
|
9
|
+
// the rows, never estimated, and a row that says nothing about the work says nothing here either.
|
|
10
|
+
//
|
|
11
|
+
// class agent x change x risk
|
|
12
|
+
// verified verify is pass or fail: the command ran and gave a verdict
|
|
13
|
+
// first try verify = pass and fixes = 0
|
|
14
|
+
// not verified verify is none (no command), blocked (the command was not allowed to run) or timeout: no
|
|
15
|
+
// verdict about the work, so these are shown apart and are in no rate
|
|
16
|
+
// escalated escalated_from is not "-": this call repeats a card that failed one model down
|
|
17
|
+
//
|
|
18
|
+
// Crewforth also writes `.claude/state/crew-model-floors.auto`: a class held one model up because its first try
|
|
19
|
+
// failed too often. Lowering such a hold is the user's to approve, with a Crewforth command in their own session:
|
|
20
|
+
// Studio shows which holds could be lowered (`suggestionsOf`) and writes nothing. A panel's button is a request
|
|
21
|
+
// any process of the same user can send, so it cannot stand for the user.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
export const COLUMNS = ['ts', 'agent_id', 'agent', 'change', 'risk', 'card', 'model', 'verify', 'fixes', 'escalated_from', 'ran_on', 'confidence'];
|
|
27
|
+
const VERIFIED = new Set(['pass', 'fail']);
|
|
28
|
+
// What the hooks write, and nothing else is a row. The words are hooks/guard-agent-model.sh's and
|
|
29
|
+
// hooks/agent-outcome.sh's; a word they do not use is a line this file cannot read, not a new kind of work.
|
|
30
|
+
export const VERIFY = ['pass', 'fail', 'none', 'blocked', 'timeout'];
|
|
31
|
+
export const CHANGES = ['text', 'feature', 'fix-known', 'fix-unknown', 'refactor', 'migration', 'security', 'architecture', 'test-run', 'test-write', 'audit'];
|
|
32
|
+
export const RISKS = ['critical', 'normal'];
|
|
33
|
+
const TIERS = ['haiku', 'sonnet', 'opus', 'fable'];
|
|
34
|
+
const MAX_BYTES = 4 * 1024 * 1024; // the record is read whole; past this only its end is, and the answer says so
|
|
35
|
+
// How many of a held class's latest verified calls have to have passed first time before lowering it is offered.
|
|
36
|
+
// A starting point; it has not been measured.
|
|
37
|
+
export const LOOSEN_AFTER = 10;
|
|
38
|
+
const TRAIL_MAX = 6;
|
|
39
|
+
|
|
40
|
+
const stateDir = (cwd) => path.join(cwd, '.claude', 'state');
|
|
41
|
+
const dash = (v) => (v === '-' || v === '' || v == null ? null : v);
|
|
42
|
+
|
|
43
|
+
/** Is this line one the hooks could have written? Every field that is counted or compared has to be what they write. */
|
|
44
|
+
function rowProblem(f) {
|
|
45
|
+
if (f.length < COLUMNS.length) return 'fewer than twelve columns';
|
|
46
|
+
if (!/^[A-Za-z0-9._-]+$/.test(f[1])) return 'no agent id';
|
|
47
|
+
if (!Number.isFinite(Date.parse(f[0])) || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(f[0])) return 'ts is not a UTC time';
|
|
48
|
+
if (!f[2]) return 'no agent';
|
|
49
|
+
if (!CHANGES.includes(f[3])) return 'change is not one of the eleven';
|
|
50
|
+
if (!RISKS.includes(f[4])) return 'risk is not critical or normal';
|
|
51
|
+
if (f[6] !== '-' && !TIERS.includes(f[6])) return 'model is not a tier';
|
|
52
|
+
if (!VERIFY.includes(f[7])) return 'verify is not one of the five';
|
|
53
|
+
if (!/^\d{1,4}$/.test(f[8])) return 'fixes is not a count';
|
|
54
|
+
if (f[9] !== '-' && !TIERS.includes(f[9])) return 'escalated_from is not a tier';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The rows of a model-outcomes.tsv. A line the hooks could not have written is dropped and counted, with the
|
|
60
|
+
* first reason of each kind kept so the view can say why the count is not zero.
|
|
61
|
+
*/
|
|
62
|
+
export function parseOutcomes(text) {
|
|
63
|
+
const lines = String(text ?? '').split('\n').map((l) => l.replace(/\r$/, '')).filter((l) => l !== '');
|
|
64
|
+
const out = { rows: [], dropped: 0, why: {}, header: false };
|
|
65
|
+
for (const [i, line] of lines.entries()) {
|
|
66
|
+
const f = line.split('\t');
|
|
67
|
+
if (i === 0 && f[0] === 'ts' && f[1] === 'agent_id') { out.header = true; continue; }
|
|
68
|
+
const problem = rowProblem(f);
|
|
69
|
+
if (problem) { out.dropped += 1; out.why[problem] = (out.why[problem] ?? 0) + 1; continue; }
|
|
70
|
+
const at = Date.parse(f[0]);
|
|
71
|
+
out.rows.push({
|
|
72
|
+
line: i, ts: f[0], at, agentId: f[1], agent: f[2], change: f[3], risk: f[4], card: dash(f[5]),
|
|
73
|
+
model: dash(f[6]), verify: f[7], fixes: Number.parseInt(f[8], 10), escalatedFrom: dash(f[9]), ranOn: dash(f[10]),
|
|
74
|
+
confidence: dash(f[11]),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The lines of crew-model-floors.auto: agent, change, risk, model, calls, not-first-try, when. Comments start with #. */
|
|
81
|
+
export function parseFloors(text) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
84
|
+
const l = line.replace(/\r$/, '');
|
|
85
|
+
if (!l || l.startsWith('#')) continue;
|
|
86
|
+
const f = l.split('\t');
|
|
87
|
+
if (f.length < 4 || !TIERS.includes(f[3])) continue;
|
|
88
|
+
out.push({ agent: f[0], change: f[1], risk: f[2], model: f[3], calls: Number.parseInt(f[4], 10) || null, notFirstTry: Number.parseInt(f[5], 10) || 0, when: f[6] ?? null });
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The user's approvals to lower a held class: ts, agent, change, risk, from, to. The newest for a class is the one that counts. */
|
|
94
|
+
export function parseLoosened(text) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const [i, line] of String(text ?? '').split('\n').entries()) {
|
|
97
|
+
const f = line.replace(/\r$/, '').split('\t');
|
|
98
|
+
if (i === 0 && f[0] === 'ts') continue;
|
|
99
|
+
if (f.length < 6 || !TIERS.includes(f[4]) || !TIERS.includes(f[5])) continue;
|
|
100
|
+
out.push({ ts: f[0], at: Date.parse(f[0]) || null, agent: f[1], change: f[2], risk: f[3], from: f[4], to: f[5] });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const keyOf = (r) => `${r.agent}\t${r.change}\t${r.risk}`;
|
|
106
|
+
const firstTry = (r) => r.verify === 'pass' && r.fixes === 0;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The lowering that stands for a raised floor, or null. The rule is hooks/guard-agent-model.sh's (`_am_floor_now`),
|
|
110
|
+
* word for word: a lowering counts when it is not older than the raise it answers (the times are UTC text and are
|
|
111
|
+
* compared as text, as the hook does), is from the model the floor was raised to, and goes one model down. Raised
|
|
112
|
+
* again afterwards, the floor is the raised one. The last line that counts is the one in force.
|
|
113
|
+
*/
|
|
114
|
+
export function loweringOf(floor, loosened = []) {
|
|
115
|
+
let hit = null;
|
|
116
|
+
for (const l of loosened) {
|
|
117
|
+
if (keyOf(l) !== keyOf(floor)) continue;
|
|
118
|
+
if (String(l.ts) < String(floor.when ?? '')) continue;
|
|
119
|
+
if (l.from !== floor.model) continue;
|
|
120
|
+
if (TIERS.indexOf(l.to) < 0 || TIERS.indexOf(l.to) !== TIERS.indexOf(l.from) - 1) continue;
|
|
121
|
+
hit = { to: l.to, ts: l.ts };
|
|
122
|
+
}
|
|
123
|
+
return hit;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One line per class, the busiest first. What the class cost is asked apart (`classCosts`): it is read from elsewhere. */
|
|
127
|
+
export function classesOf(rows, floors = [], loosened = []) {
|
|
128
|
+
const by = new Map();
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
const k = keyOf(r);
|
|
131
|
+
if (!by.has(k)) {
|
|
132
|
+
by.set(k, {
|
|
133
|
+
key: k, agent: r.agent, change: r.change, risk: r.risk, runs: 0, verified: 0, firstTry: 0, fixed: 0, failed: 0, escalated: 0,
|
|
134
|
+
notVerified: { none: 0, blocked: 0, timeout: 0 }, models: {}, ranOn: {}, lastAt: null, floor: null,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const c = by.get(k);
|
|
138
|
+
c.runs += 1;
|
|
139
|
+
const m = (c.models[r.model ?? 'unknown'] ??= { runs: 0, verified: 0, firstTry: 0 });
|
|
140
|
+
m.runs += 1;
|
|
141
|
+
if (r.ranOn) c.ranOn[r.ranOn] = (c.ranOn[r.ranOn] ?? 0) + 1;
|
|
142
|
+
if (VERIFIED.has(r.verify)) {
|
|
143
|
+
c.verified += 1; m.verified += 1;
|
|
144
|
+
if (firstTry(r)) { c.firstTry += 1; m.firstTry += 1; }
|
|
145
|
+
if (r.verify === 'fail') c.failed += 1;
|
|
146
|
+
if (r.fixes > 0) c.fixed += 1;
|
|
147
|
+
} else c.notVerified[r.verify] += 1;
|
|
148
|
+
if (r.escalatedFrom) c.escalated += 1;
|
|
149
|
+
if (r.at !== null && (c.lastAt === null || r.at > c.lastAt)) c.lastAt = r.at;
|
|
150
|
+
}
|
|
151
|
+
// A floor the user lowered is still on record as raised; what holds now is the lowered one, and both are said.
|
|
152
|
+
for (const f of floors) { const c = by.get(keyOf(f)); if (c) c.floor = { ...f, lowered: loweringOf(f, loosened) }; }
|
|
153
|
+
return [...by.values()]
|
|
154
|
+
// A rate with nothing under it is not zero: with no verified call there is none.
|
|
155
|
+
.map((c) => ({ ...c, firstTryRate: c.verified ? c.firstTry / c.verified : null }))
|
|
156
|
+
.sort((a, b) => b.runs - a.runs || a.key.localeCompare(b.key));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What each class's agents come to, where an agent's tokens could be read: { classKey: { cost, costed, runs } }.
|
|
161
|
+
* `costed` of `runs` were priced; the rest are not in the sum, so the sum is a floor and not the total.
|
|
162
|
+
* @param costOf (agentId) => USD or null
|
|
163
|
+
*/
|
|
164
|
+
export function classCosts(rows, costOf) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const r of rows) {
|
|
167
|
+
const c = (out[keyOf(r)] ??= { cost: 0, costed: 0, runs: 0 });
|
|
168
|
+
c.runs += 1;
|
|
169
|
+
const usd = costOf(r.agentId);
|
|
170
|
+
if (typeof usd === 'number') { c.cost += usd; c.costed += 1; }
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The holds that could be lowered: a class the record of outcomes holds one model up (a line in
|
|
177
|
+
* crew-model-floors.auto), whose latest LOOSEN_AFTER verified calls on that model all passed the first time.
|
|
178
|
+
* Calls made before the user last approved a lowering of that class are not counted, and a hold already lowered
|
|
179
|
+
* since it was written is not offered again.
|
|
180
|
+
*/
|
|
181
|
+
export function suggestionsOf(rows, floors = [], loosened = [], need = LOOSEN_AFTER) {
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const f of floors) {
|
|
184
|
+
const tier = TIERS.indexOf(f.model);
|
|
185
|
+
if (tier <= 0) continue;
|
|
186
|
+
const k = keyOf(f);
|
|
187
|
+
// Once for each raise: a floor whose lowering stands is not offered again (the same rule the gate reads it by).
|
|
188
|
+
if (loweringOf(f, loosened)) continue;
|
|
189
|
+
const since = Date.parse(f.when ?? '') || 0;
|
|
190
|
+
const recent = rows.filter((r) => keyOf(r) === k && r.model === f.model && VERIFIED.has(r.verify) && (r.at ?? 0) >= since)
|
|
191
|
+
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, need);
|
|
192
|
+
if (recent.length < need || !recent.every(firstTry)) continue;
|
|
193
|
+
out.push({ key: k, agent: f.agent, change: f.change, risk: f.risk, from: f.model, to: TIERS[tier - 1], passed: recent.length, heldSince: f.when ?? null });
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readBounded(file) {
|
|
199
|
+
let st;
|
|
200
|
+
try { st = fs.statSync(file); } catch { return null; }
|
|
201
|
+
if (st.size <= MAX_BYTES) return { text: fs.readFileSync(file, 'utf8'), truncated: false, bytes: st.size };
|
|
202
|
+
const fd = fs.openSync(file, 'r');
|
|
203
|
+
try {
|
|
204
|
+
const buf = Buffer.alloc(MAX_BYTES);
|
|
205
|
+
fs.readSync(fd, buf, 0, MAX_BYTES, st.size - MAX_BYTES);
|
|
206
|
+
const text = buf.toString('utf8');
|
|
207
|
+
return { text: text.slice(text.indexOf('\n') + 1), truncated: true, bytes: st.size };
|
|
208
|
+
} finally { fs.closeSync(fd); }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sigOf = (file) => { try { const st = fs.statSync(file); return `${st.mtimeMs}:${st.size}`; } catch { return '-'; } };
|
|
212
|
+
const FILES = ['model-outcomes.tsv', 'crew-model-floors.auto', 'crew-model-loosened.tsv'];
|
|
213
|
+
const records = new Map(); // cwd -> { sig, value }
|
|
214
|
+
export const _stats = { parsed: 0 }; // how many times a record was really read: the claims count it
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The record of one project, read once and kept until one of its three files changes (its time or its size).
|
|
218
|
+
* A view that asks every few seconds costs three stats, not a parse.
|
|
219
|
+
*/
|
|
220
|
+
export function readRecord(cwd) {
|
|
221
|
+
const dir = stateDir(cwd);
|
|
222
|
+
const sig = FILES.map((f) => sigOf(path.join(dir, f))).join('|');
|
|
223
|
+
const hit = records.get(cwd);
|
|
224
|
+
if (hit && hit.sig === sig) return hit.value;
|
|
225
|
+
const value = parseRecord(dir, cwd);
|
|
226
|
+
_stats.parsed += 1;
|
|
227
|
+
if (records.size > 50) records.clear();
|
|
228
|
+
records.set(cwd, { sig, value });
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseRecord(dir, cwd) {
|
|
233
|
+
const record = readBounded(path.join(dir, FILES[0]));
|
|
234
|
+
if (!record) return { measured: false, reason: 'no .claude/state/model-outcomes.tsv in this project: no crew agent has finished here since model routing was installed', classes: [], suggestions: [], byAgent: {} };
|
|
235
|
+
const parsed = parseOutcomes(record.text);
|
|
236
|
+
const floors = parseFloors(readBounded(path.join(dir, FILES[1]))?.text ?? '');
|
|
237
|
+
const loosened = parseLoosened(readBounded(path.join(dir, FILES[2]))?.text ?? '');
|
|
238
|
+
const byAgent = {};
|
|
239
|
+
for (const r of parsed.rows) byAgent[r.agentId] = r; // a resumed agent's latest line is the one that stands
|
|
240
|
+
// A card that failed is given again one model up, to another agent: the calls of one card, oldest first, are
|
|
241
|
+
// the trail the inspector shows under the newest of them.
|
|
242
|
+
const byCard = new Map();
|
|
243
|
+
for (const r of parsed.rows) if (r.card) byCard.set(`${r.agent}\t${r.card}`, [...(byCard.get(`${r.agent}\t${r.card}`) ?? []), r]);
|
|
244
|
+
for (const r of Object.values(byAgent)) {
|
|
245
|
+
const same = r.card ? (byCard.get(`${r.agent}\t${r.card}`) ?? []) : [];
|
|
246
|
+
// Earlier in the file, not earlier by the clock: two lines can carry the same second.
|
|
247
|
+
r.before = same.filter((x) => x.agentId !== r.agentId && x.line < r.line).slice(-TRAIL_MAX)
|
|
248
|
+
.map(({ agentId, model, verify, fixes, ranOn }) => ({ agentId, model, verify, fixes, ranOn }));
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
measured: true,
|
|
252
|
+
cwd,
|
|
253
|
+
rows: parsed.rows.length,
|
|
254
|
+
dropped: parsed.dropped,
|
|
255
|
+
droppedWhy: parsed.why,
|
|
256
|
+
truncated: record.truncated,
|
|
257
|
+
firstAt: parsed.rows.reduce((m, r) => (m === null || r.at < m ? r.at : m), null),
|
|
258
|
+
classes: classesOf(parsed.rows, floors, loosened),
|
|
259
|
+
floors,
|
|
260
|
+
suggestions: suggestionsOf(parsed.rows, floors, loosened),
|
|
261
|
+
loosened,
|
|
262
|
+
byAgent,
|
|
263
|
+
all: parsed.rows,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What the Models view and the inspector are sent for one project.
|
|
269
|
+
* @param agents the ids of the agents of the session on screen: only their lines are sent. Without it, none are:
|
|
270
|
+
* a project's record can hold every agent it ever ran, and a page shows one session's.
|
|
271
|
+
* @returns measured: false with a reason when the project has no record — which is a project where no crew agent
|
|
272
|
+
* has finished since model routing was installed, not a project where every agent failed.
|
|
273
|
+
*/
|
|
274
|
+
export function readModels(cwd, { agents = null } = {}) {
|
|
275
|
+
const rec = readRecord(cwd);
|
|
276
|
+
if (!rec.measured) return rec;
|
|
277
|
+
const byAgent = {};
|
|
278
|
+
for (const id of agents ?? []) if (Object.hasOwn(rec.byAgent, id)) byAgent[id] = rec.byAgent[id];
|
|
279
|
+
const { all, byAgent: _every, ...rest } = rec;
|
|
280
|
+
return { ...rest, byAgent };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** What the classes of one project cost, from the kept record: see `classCosts`. Null when there is no record. */
|
|
284
|
+
export function readCosts(cwd, costOf) {
|
|
285
|
+
const rec = readRecord(cwd);
|
|
286
|
+
return rec.measured ? classCosts(rec.all, costOf) : null;
|
|
287
|
+
}
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// values below give each of those names one shade, so an agent keeps the same
|
|
5
5
|
// colour in every session the panel draws.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// The map holds Crewforth's own agents and nothing else: the ones in its own
|
|
8
|
+
// agents directory. There is no list of Claude Code's built-in agent types
|
|
9
|
+
// here. There was one, and it went stale the first time Claude Code added a
|
|
10
|
+
// type: its own "Workflow Subagent" was drawn as "not declared by Claude Code".
|
|
11
|
+
// Whatever is not Crewforth's is a Claude Code agent, whoever defined it.
|
|
10
12
|
|
|
11
13
|
import fs from 'node:fs';
|
|
12
14
|
import path from 'node:path';
|
|
@@ -46,18 +48,6 @@ const NAMED = {
|
|
|
46
48
|
yellow: '#db5f1c',
|
|
47
49
|
};
|
|
48
50
|
|
|
49
|
-
// Claude Code's built-in agent types are not kit components and declare
|
|
50
|
-
// nothing, so they take the diagram's "core" tones.
|
|
51
|
-
const BUILTIN = {
|
|
52
|
-
Explore: '#26c6e6',
|
|
53
|
-
Plan: '#a874f5',
|
|
54
|
-
'general-purpose': '#c79bff',
|
|
55
|
-
claude: '#5b8cff',
|
|
56
|
-
'code-simplifier': '#84e6b0',
|
|
57
|
-
'statusline-setup': '#94a3c8',
|
|
58
|
-
'claude-code-guide': '#8659ee',
|
|
59
|
-
};
|
|
60
|
-
|
|
61
51
|
const UNKNOWN = '#94a3c8';
|
|
62
52
|
|
|
63
53
|
let cached = null;
|
|
@@ -92,9 +82,6 @@ export function palette() {
|
|
|
92
82
|
for (const [name, v] of Object.entries(kit ?? {})) {
|
|
93
83
|
map[name] = { hex: v.hex ?? UNKNOWN, declared: v.color, known: Boolean(v.hex), source: 'kit' };
|
|
94
84
|
}
|
|
95
|
-
for (const [name, hex] of Object.entries(BUILTIN)) {
|
|
96
|
-
if (!map[name]) map[name] = { hex, declared: null, known: true, source: 'builtin' };
|
|
97
|
-
}
|
|
98
85
|
cached = {
|
|
99
86
|
map,
|
|
100
87
|
unknown: UNKNOWN,
|