catherd-cli 0.2.1 → 1.1.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/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
|
|
3
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
4
|
+
import { readSessionRows } from "./sessions.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spec §4: runs grouped by the Claude Code session that drove them. A run belongs to the session that started it
|
|
8
|
+
* (`meta.startedBy`); a run listed in another session's rows of `sessions.jsonl` also appears under that session,
|
|
9
|
+
* as "continued here", and under its starting session it says "continued in <name>", naming the session that
|
|
10
|
+
* owns it now (the last row of `sessions.jsonl`; a run that came back to its starter is not "continued" at
|
|
11
|
+
* all). Only the current owner's group counts its live roles and landings. Runs from 1.0 (no
|
|
12
|
+
* `startedBy`) go under "earlier runs".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A session as the runs page shows it: its name read live while it runs, else the last one recorded. */
|
|
16
|
+
export interface RunSession {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
hostSessionId: string | null;
|
|
19
|
+
name: string;
|
|
20
|
+
/** the session's process runs now */
|
|
21
|
+
live: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GroupedRun {
|
|
25
|
+
run: Run;
|
|
26
|
+
/** "here": this session continued a run another started; "elsewhere": another session continued it */
|
|
27
|
+
continued: "here" | "elsewhere" | null;
|
|
28
|
+
/** the session that owns it now, for "elsewhere" */
|
|
29
|
+
continuedIn: string | null;
|
|
30
|
+
/** the run lives on in this session now (it is the run's current owner): its live roles count here */
|
|
31
|
+
current: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionGroup {
|
|
35
|
+
/** null: "earlier runs", the 1.0 runs no session is recorded for */
|
|
36
|
+
session: RunSession | null;
|
|
37
|
+
runs: GroupedRun[];
|
|
38
|
+
/** when anything last happened in one of its runs, ms since the epoch */
|
|
39
|
+
lastActivity: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Newest of the run's creation and its state and record files' last writes. */
|
|
43
|
+
export function runActivity(run: Run): number {
|
|
44
|
+
const p = runPaths(run.dir);
|
|
45
|
+
let t = Date.parse(run.meta.createdAt) || 0;
|
|
46
|
+
for (const f of [p.stateJson, p.runs, p.state, p.agents]) {
|
|
47
|
+
try {
|
|
48
|
+
t = Math.max(t, statSync(f).mtimeMs);
|
|
49
|
+
} catch {
|
|
50
|
+
// not written yet
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The sessions each run has had, oldest first: its starter, then each one that took it over. */
|
|
57
|
+
function trailOf(
|
|
58
|
+
run: Run,
|
|
59
|
+
): { sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
|
|
60
|
+
const rows = readSessionRows(run);
|
|
61
|
+
const s = run.meta.startedBy;
|
|
62
|
+
if (s && !rows.some((r) => r.sessionId === s.sessionId)) return [{ ...s, at: run.meta.createdAt }, ...rows];
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every run grouped by session, the groups newest activity first, the runs in each newest first. */
|
|
67
|
+
export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()): SessionGroup[] {
|
|
68
|
+
// the last name recorded for each session, in any run
|
|
69
|
+
const recorded = new Map<string, { name: string | null; host: string | null; at: string }>();
|
|
70
|
+
const trails = new Map(runs.map((r) => [r.id, trailOf(r)]));
|
|
71
|
+
for (const t of trails.values())
|
|
72
|
+
for (const row of t) {
|
|
73
|
+
const was = recorded.get(row.sessionId);
|
|
74
|
+
if (!was || row.at >= was.at)
|
|
75
|
+
recorded.set(row.sessionId, {
|
|
76
|
+
name: row.name ?? was?.name ?? null,
|
|
77
|
+
host: row.hostSessionId ?? was?.host ?? null,
|
|
78
|
+
at: row.at,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const sessionOf = (id: string): RunSession => {
|
|
82
|
+
const live = liveSessionFile(id, files);
|
|
83
|
+
const rec = recorded.get(id);
|
|
84
|
+
return {
|
|
85
|
+
sessionId: id,
|
|
86
|
+
hostSessionId: live?.hostSessionId ?? rec?.host ?? null,
|
|
87
|
+
name: live?.name ?? rec?.name ?? `session ${id.slice(0, 8)}`,
|
|
88
|
+
live: live !== null,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const groups = new Map<string | null, SessionGroup>();
|
|
92
|
+
const add = (id: string | null, g: GroupedRun, at: number) => {
|
|
93
|
+
let group = groups.get(id);
|
|
94
|
+
if (!group) {
|
|
95
|
+
group = { session: id === null ? null : sessionOf(id), runs: [], lastActivity: 0 };
|
|
96
|
+
groups.set(id, group);
|
|
97
|
+
}
|
|
98
|
+
group.runs.push(g);
|
|
99
|
+
group.lastActivity = Math.max(group.lastActivity, at);
|
|
100
|
+
};
|
|
101
|
+
for (const run of runs) {
|
|
102
|
+
const at = runActivity(run);
|
|
103
|
+
const trail = trails.get(run.id) ?? [];
|
|
104
|
+
const starter = run.meta.startedBy?.sessionId ?? null;
|
|
105
|
+
// the run lives on in its current owner: the last session that took it (a run can come back to its starter)
|
|
106
|
+
const owner = trail.at(-1)?.sessionId ?? starter;
|
|
107
|
+
const moved = owner !== starter && owner !== null;
|
|
108
|
+
// a run that lives on elsewhere counts, in a session it left, from when that session last took it
|
|
109
|
+
const tookAt = (id: string | null): number =>
|
|
110
|
+
Date.parse(trail.findLast((t) => t.sessionId === id)?.at ?? run.meta.createdAt) || 0;
|
|
111
|
+
add(
|
|
112
|
+
starter,
|
|
113
|
+
{
|
|
114
|
+
run,
|
|
115
|
+
continued: moved ? "elsewhere" : null,
|
|
116
|
+
continuedIn: moved ? sessionOf(owner).name : null,
|
|
117
|
+
current: !moved,
|
|
118
|
+
},
|
|
119
|
+
moved ? tookAt(starter) : at,
|
|
120
|
+
);
|
|
121
|
+
for (const id of new Set(trail.map((t) => t.sessionId)))
|
|
122
|
+
if (id !== starter)
|
|
123
|
+
add(
|
|
124
|
+
id,
|
|
125
|
+
{ run, continued: "here", continuedIn: null, current: id === owner },
|
|
126
|
+
id === owner ? at : tookAt(id),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const out = [...groups.values()];
|
|
130
|
+
for (const g of out)
|
|
131
|
+
g.runs.sort(
|
|
132
|
+
(a, b) =>
|
|
133
|
+
runActivity(b.run) - runActivity(a.run) || b.run.meta.createdAt.localeCompare(a.run.meta.createdAt),
|
|
134
|
+
);
|
|
135
|
+
// "earlier runs" last, whatever its activity: 1.0 runs are history
|
|
136
|
+
return out.sort((a, b) =>
|
|
137
|
+
a.session === null ? 1 : b.session === null ? -1 : b.lastActivity - a.lastActivity,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The session a run started in, as the runs page names it now (null for a run from before 1.1), and the session
|
|
143
|
+
* that continued it, if another did.
|
|
144
|
+
*/
|
|
145
|
+
export function sessionFacts(
|
|
146
|
+
run: Run,
|
|
147
|
+
files: SessionFile[] = readSessionFiles(),
|
|
148
|
+
): { session: RunSession | null; continuedIn: string | null } {
|
|
149
|
+
const starter = run.meta.startedBy?.sessionId ?? null;
|
|
150
|
+
const own = groupRuns([run], files).find((g) => (g.session?.sessionId ?? null) === starter);
|
|
151
|
+
return { session: own?.session ?? null, continuedIn: own?.runs[0]?.continuedIn ?? null };
|
|
152
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { sessionFileFor } from "../infra/claude-session.ts";
|
|
3
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
4
|
+
import { appendJsonl, ensureJsonlHeader, readJsonl, writeJsonAtomic } from "../infra/store.ts";
|
|
5
|
+
import type { Deps } from "./ports.ts";
|
|
6
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
7
|
+
import { readNotes } from "./state.ts";
|
|
8
|
+
|
|
9
|
+
/** Spec §4: a Claude Code session as catherd records it. */
|
|
10
|
+
export interface SessionRef {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
hostSessionId: string | null;
|
|
13
|
+
name: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The session this process serves (spec §3.3), read live: its registry file's id and name when it has one (the
|
|
18
|
+
* id in the environment goes stale after /clear), else the environment's. Null outside Claude Code.
|
|
19
|
+
*/
|
|
20
|
+
export function currentSession(deps: Deps): SessionRef | null {
|
|
21
|
+
const env = deps.session;
|
|
22
|
+
if (!env) return null;
|
|
23
|
+
const file = sessionFileFor(env);
|
|
24
|
+
const sessionId = file?.sessionId ?? env.sessionId;
|
|
25
|
+
if (!sessionId) return null;
|
|
26
|
+
return { sessionId, hostSessionId: file?.hostSessionId ?? env.hostSessionId, name: file?.name ?? null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const OwnerSchema = z.object({ sessionId: z.string(), since: z.string() });
|
|
30
|
+
export type Owner = z.infer<typeof OwnerSchema>;
|
|
31
|
+
|
|
32
|
+
/** The run's owner session (state.json `owner`), or null for a run no session has owned (1.0, a terminal). */
|
|
33
|
+
export function runOwner(run: Run): Owner | null {
|
|
34
|
+
const r = OwnerSchema.safeParse((readNotes(run) as Record<string, unknown>).owner);
|
|
35
|
+
return r.success ? r.data : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether this process serves a session and that session owns the run now. */
|
|
39
|
+
export function ownsRun(deps: Deps, run: Run): boolean {
|
|
40
|
+
const me = currentSession(deps);
|
|
41
|
+
return me !== null && runOwner(run)?.sessionId === me.sessionId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SessionRowSchema = z.looseObject({
|
|
45
|
+
sessionId: z.string(),
|
|
46
|
+
hostSessionId: z.string().nullable(),
|
|
47
|
+
name: z.string().nullable(),
|
|
48
|
+
at: z.string(),
|
|
49
|
+
});
|
|
50
|
+
export type SessionRow = z.infer<typeof SessionRowSchema>;
|
|
51
|
+
|
|
52
|
+
/** `R/sessions.jsonl`: every session that has owned the run, in the order they took it. */
|
|
53
|
+
export function readSessionRows(run: Run): SessionRow[] {
|
|
54
|
+
return readJsonl<unknown>(runPaths(run.dir).sessions).rows.flatMap((row) => {
|
|
55
|
+
const r = SessionRowSchema.safeParse(row);
|
|
56
|
+
return r.success ? [r.data] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
|
|
62
|
+
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Outside
|
|
63
|
+
* Claude Code it changes nothing. Returns whether the owner changed.
|
|
64
|
+
*/
|
|
65
|
+
export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
66
|
+
const me = currentSession(deps);
|
|
67
|
+
if (!me) return false;
|
|
68
|
+
const p = runPaths(run.dir);
|
|
69
|
+
const at = new Date(deps.now()).toISOString();
|
|
70
|
+
const row = (since: string): void => {
|
|
71
|
+
ensureJsonlHeader(p.sessions, "sessions");
|
|
72
|
+
appendJsonl(p.sessions, {
|
|
73
|
+
sessionId: me.sessionId,
|
|
74
|
+
hostSessionId: me.hostSessionId,
|
|
75
|
+
name: me.name,
|
|
76
|
+
at: since,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
// the trail row goes in under the same lock: the trail's last row is the owner (session-view reads it so)
|
|
80
|
+
return withFileLock(p.stateJson, () => {
|
|
81
|
+
const notes = readNotes(run);
|
|
82
|
+
const owner = runOwner(run);
|
|
83
|
+
if (owner?.sessionId === me.sessionId) {
|
|
84
|
+
// a crash between the state.json write and the append left the trail without its owner: repaired here
|
|
85
|
+
if (readSessionRows(run).at(-1)?.sessionId !== me.sessionId) row(owner.since);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
writeJsonAtomic(p.stateJson, { ...notes, owner: { sessionId: me.sessionId, since: at } });
|
|
89
|
+
row(at);
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type { Issue } from "../domain/profile-rules.ts";
|
|
4
|
+
import { configDir } from "../infra/paths.ts";
|
|
5
|
+
import { type Refreshed, refreshDiscovery } from "./catalog-service.ts";
|
|
6
|
+
import type { Synced } from "./agent-links.ts";
|
|
7
|
+
import { activate, resetProfile, withProfilesLock } from "./profile-service.ts";
|
|
8
|
+
import { configFile, profilesDir, projectsFile } from "./profile-store.ts";
|
|
9
|
+
import { ensurePrivateDir } from "../infra/store.ts";
|
|
10
|
+
|
|
11
|
+
/** True when profile `name` has a file (`default` exists as a name even before it has one). */
|
|
12
|
+
export const hasProfileFile = (name: string): boolean => existsSync(join(profilesDir(), `${name}.json`));
|
|
13
|
+
|
|
14
|
+
/** A JSON file written by catherd 0.x: readable JSON with no `schema` field. */
|
|
15
|
+
function isLegacy(file: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const v = JSON.parse(readFileSync(file, "utf8")) as { schema?: unknown } | null;
|
|
18
|
+
return typeof v === "object" && v !== null && v.schema === undefined;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Spec D2, a clean break: 1.0 never reads a 0.x config.json, projects.json or profile, so `init` moves
|
|
26
|
+
* them to `<config>/0.x-backup-<stamp>/`, under the profiles lock. Returns the files moved.
|
|
27
|
+
*/
|
|
28
|
+
export function moveLegacy(now: Date = new Date()): string[] {
|
|
29
|
+
return withProfilesLock(() => moveLegacyUnlocked(now));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function moveLegacyUnlocked(now: Date): string[] {
|
|
33
|
+
const candidates = [
|
|
34
|
+
configFile(),
|
|
35
|
+
projectsFile(),
|
|
36
|
+
...(existsSync(profilesDir())
|
|
37
|
+
? readdirSync(profilesDir())
|
|
38
|
+
.filter((f) => f.endsWith(".json"))
|
|
39
|
+
.map((f) => join(profilesDir(), f))
|
|
40
|
+
: []),
|
|
41
|
+
].filter((f) => existsSync(f) && isLegacy(f));
|
|
42
|
+
if (candidates.length === 0) return [];
|
|
43
|
+
const backup = join(configDir(), `0.x-backup-${now.toISOString().replace(/[:.]/g, "-")}`);
|
|
44
|
+
ensurePrivateDir(join(backup, "profiles"));
|
|
45
|
+
return candidates.map((f) => {
|
|
46
|
+
const to = join(backup, f.startsWith(profilesDir()) ? join("profiles", basename(f)) : basename(f));
|
|
47
|
+
renameSync(f, to);
|
|
48
|
+
return to;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface InitResult {
|
|
53
|
+
moved: string[];
|
|
54
|
+
profile: string;
|
|
55
|
+
/** true when the default profile was written under `profile` */
|
|
56
|
+
created: boolean;
|
|
57
|
+
/** why the default profile was not written: it does not validate against this machine's catalog */
|
|
58
|
+
errors: Issue[];
|
|
59
|
+
/** false when nothing was made active: the profile was not written and has no file */
|
|
60
|
+
active: boolean;
|
|
61
|
+
synced: Synced | null;
|
|
62
|
+
refreshed: Refreshed[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Spec §8 `catherd init`, the setup half: moves 0.x files aside, writes the default profile (spec §7.2)
|
|
67
|
+
* unless a 1.0 one exists and `overwrite` is not set, makes it active and links its agents, and lists
|
|
68
|
+
* every backend's models. It never throws for a default profile that does not validate here: it writes
|
|
69
|
+
* nothing, returns the errors, and activates the profile only when it has a file.
|
|
70
|
+
*/
|
|
71
|
+
export async function initSetup(
|
|
72
|
+
o: { profile?: string; overwrite?: boolean; now?: Date } = {},
|
|
73
|
+
): Promise<InitResult> {
|
|
74
|
+
const moved = moveLegacy(o.now);
|
|
75
|
+
const profile = o.profile ?? "default";
|
|
76
|
+
let created = false;
|
|
77
|
+
let errors: Issue[] = [];
|
|
78
|
+
if (!hasProfileFile(profile) || o.overwrite === true) {
|
|
79
|
+
const saved = resetProfile(profile);
|
|
80
|
+
created = saved.saved;
|
|
81
|
+
errors = saved.errors;
|
|
82
|
+
}
|
|
83
|
+
const active = hasProfileFile(profile);
|
|
84
|
+
const synced = active ? activate(profile) : null;
|
|
85
|
+
const refreshed = await refreshDiscovery();
|
|
86
|
+
return { moved, profile, created, errors, active, synced, refreshed };
|
|
87
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
4
|
+
import { overlaps } from "../domain/lane.ts";
|
|
5
|
+
import { renderState, withParked } from "../domain/state.ts";
|
|
6
|
+
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
8
|
+
import { gitHead, statusSnapshot } from "../infra/git.ts";
|
|
9
|
+
import { readVersioned, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
10
|
+
import { liveDispatches } from "./dispatches.ts";
|
|
11
|
+
import { protocolNext } from "./protocol.ts";
|
|
12
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
13
|
+
|
|
14
|
+
/** state.json: the orchestrator's notes that state.md shows beside the live facts. */
|
|
15
|
+
const NotesSchema = z.looseObject({
|
|
16
|
+
schema: z.literal(1),
|
|
17
|
+
next: z.string(),
|
|
18
|
+
lastCheck: z.string().nullable(),
|
|
19
|
+
lastLandedAt: z.string().nullable(),
|
|
20
|
+
/** spec 1.1 §8: the milestones waiting on the owner */
|
|
21
|
+
parked: z.array(z.string()).optional(),
|
|
22
|
+
});
|
|
23
|
+
export type Notes = z.infer<typeof NotesSchema>;
|
|
24
|
+
export type NotesPatch = Partial<Omit<Notes, "schema">>;
|
|
25
|
+
|
|
26
|
+
/** Every writer's next step keeps the parked milestones in front of it (spec 1.1 §8). */
|
|
27
|
+
const parkedNext = (n: Notes): Notes => ({ ...n, next: withParked(n.next, n.parked ?? []) });
|
|
28
|
+
|
|
29
|
+
const FRESH: Notes = { schema: 1, next: "plan the milestones", lastCheck: null, lastLandedAt: null };
|
|
30
|
+
|
|
31
|
+
export function readNotes(run: Run): Notes {
|
|
32
|
+
try {
|
|
33
|
+
return readVersioned(runPaths(run.dir).stateJson, NotesSchema, 1);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (isCatherdError(e) && e.code === "E_CONFIG_NEWER_SCHEMA") throw e;
|
|
36
|
+
return { ...FRESH };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Rewrites state.json and state.md under the run's state lock, so concurrent writers never lose a
|
|
42
|
+
* field (audit C11). `change` may be a function of the current notes; it runs inside the lock.
|
|
43
|
+
* Throws E_IO_UNEXPECTED, writing nothing, when git fails: state.md must not show a broken git as clean.
|
|
44
|
+
*/
|
|
45
|
+
export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesPatch) = {}): Promise<string> {
|
|
46
|
+
const p = runPaths(run.dir);
|
|
47
|
+
return withFileLock(p.stateJson, async () => {
|
|
48
|
+
// git first: a failed snapshot throws before either file is written, so they never disagree
|
|
49
|
+
const [head, snap] = await Promise.all([gitHead(run.meta.repo), statusSnapshot(run.meta.repo)]);
|
|
50
|
+
const notes = readNotes(run);
|
|
51
|
+
const next = parkedNext({ ...notes, ...(typeof change === "function" ? change(notes) : change) });
|
|
52
|
+
writeJsonAtomic(p.stateJson, next);
|
|
53
|
+
const live = liveDispatches(run);
|
|
54
|
+
const text = renderState({
|
|
55
|
+
title: run.meta.title,
|
|
56
|
+
head: head ?? "none",
|
|
57
|
+
dirty: Object.keys(snap)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((path) => ({
|
|
60
|
+
path,
|
|
61
|
+
owner: live.find((d) => overlaps([path], d.admit.owns).length > 0)?.admit.name ?? null,
|
|
62
|
+
})),
|
|
63
|
+
running: live.map((d) => ({
|
|
64
|
+
name: d.admit.name,
|
|
65
|
+
rung: d.admit.rung,
|
|
66
|
+
thread: d.admit.thread,
|
|
67
|
+
since: d.admit.admittedAt.slice(11, 16),
|
|
68
|
+
brief: relative(run.dir, dispatchPaths(d.dir).brief),
|
|
69
|
+
})),
|
|
70
|
+
lastCheck: next.lastCheck,
|
|
71
|
+
next: next.next,
|
|
72
|
+
protocol: protocolNext(run, next.parked ?? []),
|
|
73
|
+
});
|
|
74
|
+
writeTextAtomic(p.state, text);
|
|
75
|
+
return text;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ruling (b): a failed state.md refresh (git broken) never fails the call. The notes still go to state.json
|
|
81
|
+
* under the state lock (state.md stays as it was), and the message comes back as a hint.
|
|
82
|
+
*/
|
|
83
|
+
export async function refreshState(
|
|
84
|
+
run: Run,
|
|
85
|
+
change: NotesPatch | ((n: Notes) => NotesPatch) = {},
|
|
86
|
+
): Promise<{ text: string | null; hints: string[] }> {
|
|
87
|
+
let applied = false;
|
|
88
|
+
const apply = (n: Notes): NotesPatch => {
|
|
89
|
+
applied = true;
|
|
90
|
+
return typeof change === "function" ? change(n) : change;
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
return { text: await updateState(run, apply), hints: [] };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const hints = [`state.md not refreshed: ${errorMessage(e)}`];
|
|
96
|
+
// git fails before updateState reaches the notes: keep them, so a later refresh still shows them
|
|
97
|
+
if (!applied) {
|
|
98
|
+
const stateJson = runPaths(run.dir).stateJson;
|
|
99
|
+
try {
|
|
100
|
+
await withFileLock(stateJson, () => {
|
|
101
|
+
const notes = readNotes(run);
|
|
102
|
+
writeJsonAtomic(stateJson, parkedNext({ ...notes, ...apply(notes) }));
|
|
103
|
+
});
|
|
104
|
+
} catch (e2) {
|
|
105
|
+
// the lock outwaited (a slow git under another refresh): still never fail the call
|
|
106
|
+
hints.push(`notes not saved: ${errorMessage(e2)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { text: null, hints };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { type BudgetStatus, budgetStatus } from "../domain/budget.ts";
|
|
3
|
+
import { isCatherdError } from "../domain/errors.ts";
|
|
4
|
+
import type { Tokens } from "../domain/record.ts";
|
|
5
|
+
import { median } from "../domain/util.ts";
|
|
6
|
+
import { nonBlankLines, readJsonl } from "../infra/store.ts";
|
|
7
|
+
import { spendOf } from "./budget.ts";
|
|
8
|
+
import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
|
|
9
|
+
import { type OpenQuestion, openQuestions } from "./questions.ts";
|
|
10
|
+
import { type DispatchState, liveDispatches } from "./dispatches.ts";
|
|
11
|
+
import { type RunSession, sessionFacts } from "./session-view.ts";
|
|
12
|
+
import type { Deps } from "./ports.ts";
|
|
13
|
+
import {
|
|
14
|
+
findRun,
|
|
15
|
+
listRuns,
|
|
16
|
+
readAgentRuns,
|
|
17
|
+
readRecords,
|
|
18
|
+
readRoutes,
|
|
19
|
+
type Run,
|
|
20
|
+
runPaths,
|
|
21
|
+
} from "./run-store.ts";
|
|
22
|
+
|
|
23
|
+
export interface RunSummary {
|
|
24
|
+
id: string;
|
|
25
|
+
/** spec 1.1 §8: the owner questions not answered yet, listed first */
|
|
26
|
+
questions: OpenQuestion[];
|
|
27
|
+
title: string;
|
|
28
|
+
repo: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
/** spec §4: the Claude Code session that started it (null before 1.1), named live while it runs */
|
|
31
|
+
session: RunSession | null;
|
|
32
|
+
/** the session that continued it, when another did */
|
|
33
|
+
continuedIn: string | null;
|
|
34
|
+
stateTail: string[];
|
|
35
|
+
live: { name: string; rung: string; state: DispatchState; secs: number; dispatchId: string }[];
|
|
36
|
+
totals: { runs: number; ok: number; notOk: string[]; tokens: Tokens; costUsd: number; wallMinutes: number };
|
|
37
|
+
/** native subagent runs, as the orchestrator reported them: reported, not measured (spec §14) */
|
|
38
|
+
agents: { runs: number; totalTokens: number; costUsd: number };
|
|
39
|
+
jev: { decisions: number; fallbacks: number };
|
|
40
|
+
/** per backend, how many of this run's dispatches ran with the user's harness (native) and isolated */
|
|
41
|
+
harness: { backend: string; native: number; isolated: number }[];
|
|
42
|
+
budget: BudgetStatus | null;
|
|
43
|
+
milestones: string[];
|
|
44
|
+
/** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is */
|
|
45
|
+
verifier: VerifierStep | null;
|
|
46
|
+
warnings: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One run at a glance. Spec §3.4: reads are pure; nothing here finalizes a dispatch or writes a file. */
|
|
50
|
+
export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
51
|
+
const now = deps.now();
|
|
52
|
+
const { records, corrupt } = readRecords(run);
|
|
53
|
+
const live = liveDispatches(run, now);
|
|
54
|
+
const agents = readAgentRuns(run);
|
|
55
|
+
const jev = readJsonl<{ source?: string }>(runPaths(run.dir).jev).rows;
|
|
56
|
+
const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
|
|
57
|
+
let budget: BudgetStatus | null = null;
|
|
58
|
+
const warnings = corrupt ? [`runs.jsonl: skipped ${corrupt} unreadable row(s)`] : [];
|
|
59
|
+
try {
|
|
60
|
+
budget = budgetStatus(spendOf(run, records, live, now), deps.profiles.forRepo(run.meta.repo).budget);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
id: run.id,
|
|
66
|
+
questions: openQuestions(run),
|
|
67
|
+
title: run.meta.title,
|
|
68
|
+
repo: run.meta.repo,
|
|
69
|
+
createdAt: run.meta.createdAt,
|
|
70
|
+
...sessionFacts(run),
|
|
71
|
+
stateTail: nonBlankLines(runPaths(run.dir).state).slice(-3),
|
|
72
|
+
live: live.map((d) => ({
|
|
73
|
+
name: d.admit.name,
|
|
74
|
+
rung: d.admit.rung,
|
|
75
|
+
state: d.state,
|
|
76
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
77
|
+
dispatchId: d.admit.dispatchId,
|
|
78
|
+
})),
|
|
79
|
+
totals: {
|
|
80
|
+
runs: records.length,
|
|
81
|
+
ok: records.filter((r) => r.status === "ok").length,
|
|
82
|
+
notOk: records.filter((r) => r.status !== "ok").map((r) => `${r.name} (${r.status})`),
|
|
83
|
+
tokens: { input: sum((t) => t.input), cached: sum((t) => t.cached), output: sum((t) => t.output) },
|
|
84
|
+
costUsd: records.reduce((n, r) => n + (r.costUsd ?? 0), 0),
|
|
85
|
+
wallMinutes: Math.round((now - Date.parse(run.meta.createdAt)) / 60_000),
|
|
86
|
+
},
|
|
87
|
+
agents: {
|
|
88
|
+
runs: agents.length,
|
|
89
|
+
totalTokens: agents.reduce((n, a) => n + a.totalTokens, 0),
|
|
90
|
+
costUsd: agents.reduce((n, a) => n + (a.costUsd ?? 0), 0),
|
|
91
|
+
},
|
|
92
|
+
// a jev-kind, lane or default source is a decision Jev did not make whole
|
|
93
|
+
jev: { decisions: jev.length, fallbacks: jev.filter((j) => j.source !== "jev").length },
|
|
94
|
+
harness: [...new Set(records.map((r) => r.backend))].sort().map((backend) => {
|
|
95
|
+
const of = records.filter((r) => r.backend === backend);
|
|
96
|
+
return {
|
|
97
|
+
backend,
|
|
98
|
+
native: of.filter((r) => !r.isolated).length,
|
|
99
|
+
isolated: of.filter((r) => r.isolated).length,
|
|
100
|
+
};
|
|
101
|
+
}),
|
|
102
|
+
budget,
|
|
103
|
+
milestones: nonBlankLines(runPaths(run.dir).ledger).slice(1),
|
|
104
|
+
verifier: latestVerifierStep(run),
|
|
105
|
+
warnings,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `status(run?)`: that run; without one, every run with a live role, else the newest run. */
|
|
110
|
+
export function status(
|
|
111
|
+
deps: Deps,
|
|
112
|
+
runId?: string,
|
|
113
|
+
): { version: string; runs: RunSummary[]; warnings: string[] } {
|
|
114
|
+
if (runId) return { version: deps.version, runs: [summarizeRun(deps, findRun(runId))], warnings: [] };
|
|
115
|
+
const { runs, corrupt } = listRuns();
|
|
116
|
+
const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
|
|
117
|
+
const all = runs.map((r) => summarizeRun(deps, r));
|
|
118
|
+
const live = all.filter((s) => s.live.length > 0);
|
|
119
|
+
return { version: deps.version, runs: live.length ? live : all.slice(0, 1), warnings };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface RungStats {
|
|
123
|
+
role: string;
|
|
124
|
+
rung: string;
|
|
125
|
+
runs: number;
|
|
126
|
+
ok: number;
|
|
127
|
+
refusals: number;
|
|
128
|
+
climbsFrom: number;
|
|
129
|
+
secs: number;
|
|
130
|
+
tokens: Tokens;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface HarnessCost {
|
|
134
|
+
backend: string;
|
|
135
|
+
nativeRuns: number;
|
|
136
|
+
isolatedRuns: number;
|
|
137
|
+
nativeMedian: number | null;
|
|
138
|
+
isolatedMedian: number | null;
|
|
139
|
+
extraPerRun: number | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A median in whole tokens: an even count's mean of the two middle values is rounded. */
|
|
143
|
+
function tokenMedian(xs: number[]): number | null {
|
|
144
|
+
const m = median(xs);
|
|
145
|
+
return m === null ? null : Math.round(m);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Fewer first-turn samples than this on either side, and the harness line gives no figure. */
|
|
149
|
+
const HARNESS_MIN_RUNS = 3;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What the user's own harness setup costs per run: median first-turn input, native against isolated.
|
|
153
|
+
* No figure below HARNESS_MIN_RUNS a side, nor when native is not dearer: noise, not a cost.
|
|
154
|
+
*/
|
|
155
|
+
function harnessCosts(runs: Run[]): HarnessCost[] {
|
|
156
|
+
type Row = { backend: string; isolated: boolean; firstTurnInput: number };
|
|
157
|
+
const rows = runs.flatMap((r) => readJsonl<Row>(runPaths(r.dir).harness).rows);
|
|
158
|
+
return [...new Set(rows.map((r) => r.backend))].sort().map((backend) => {
|
|
159
|
+
const pick = (isolated: boolean) =>
|
|
160
|
+
rows.filter((r) => r.backend === backend && r.isolated === isolated).map((r) => r.firstTurnInput);
|
|
161
|
+
const nativeRuns = pick(false).length;
|
|
162
|
+
const isolatedRuns = pick(true).length;
|
|
163
|
+
const native = tokenMedian(pick(false));
|
|
164
|
+
const isolated = tokenMedian(pick(true));
|
|
165
|
+
const enough = nativeRuns >= HARNESS_MIN_RUNS && isolatedRuns >= HARNESS_MIN_RUNS;
|
|
166
|
+
const extra = native !== null && isolated !== null ? native - isolated : 0;
|
|
167
|
+
return {
|
|
168
|
+
backend,
|
|
169
|
+
nativeRuns,
|
|
170
|
+
isolatedRuns,
|
|
171
|
+
nativeMedian: native,
|
|
172
|
+
isolatedMedian: isolated,
|
|
173
|
+
extraPerRun: enough && extra > 0 ? extra : null,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Per role and rung over past runs: runs, ok, refusals, climbs from it, seconds and tokens. Reads only. */
|
|
179
|
+
export function runsSummary(f: { run?: string; repo?: string; role?: string; sinceDays?: number }): {
|
|
180
|
+
rungs: RungStats[];
|
|
181
|
+
agents: { rung: string; runs: number; totalTokens: number; secs: number }[];
|
|
182
|
+
harness: HarnessCost[];
|
|
183
|
+
} {
|
|
184
|
+
const since = f.sinceDays ? Date.now() - f.sinceDays * 86_400_000 : 0;
|
|
185
|
+
const repo = f.repo ? resolve(f.repo) : null;
|
|
186
|
+
const all = listRuns().runs.filter(
|
|
187
|
+
(r) => (!repo || r.meta.repo === repo) && Date.parse(r.meta.createdAt) >= since,
|
|
188
|
+
);
|
|
189
|
+
const runs = all.filter((r) => !f.run || r.id === f.run);
|
|
190
|
+
// with a run given, the harness line compares like with like: every run of that run's repo
|
|
191
|
+
const harnessRepo = f.run ? runs[0]?.meta.repo : undefined;
|
|
192
|
+
const harnessRuns = f.run ? all.filter((r) => r.meta.repo === harnessRepo) : runs;
|
|
193
|
+
const stats = new Map<string, RungStats>();
|
|
194
|
+
const at = (role: string, rung: string): RungStats => {
|
|
195
|
+
const key = `${role} ${rung}`;
|
|
196
|
+
let s = stats.get(key);
|
|
197
|
+
if (!s) {
|
|
198
|
+
s = {
|
|
199
|
+
role,
|
|
200
|
+
rung,
|
|
201
|
+
runs: 0,
|
|
202
|
+
ok: 0,
|
|
203
|
+
refusals: 0,
|
|
204
|
+
climbsFrom: 0,
|
|
205
|
+
secs: 0,
|
|
206
|
+
tokens: { input: 0, cached: 0, output: 0 },
|
|
207
|
+
};
|
|
208
|
+
stats.set(key, s);
|
|
209
|
+
}
|
|
210
|
+
return s;
|
|
211
|
+
};
|
|
212
|
+
const agents = new Map<string, { rung: string; runs: number; totalTokens: number; secs: number }>();
|
|
213
|
+
for (const run of runs) {
|
|
214
|
+
for (const r of readRecords(run).records) {
|
|
215
|
+
if (f.role && r.role !== f.role) continue;
|
|
216
|
+
const s = at(r.role, r.rung);
|
|
217
|
+
s.runs++;
|
|
218
|
+
if (r.status === "ok") s.ok++;
|
|
219
|
+
if (r.replyStatus === "refused" || r.replyStatus === "blocked") s.refusals++;
|
|
220
|
+
s.secs += r.secs;
|
|
221
|
+
s.tokens.input += r.tokens.input;
|
|
222
|
+
s.tokens.cached += r.tokens.cached;
|
|
223
|
+
s.tokens.output += r.tokens.output;
|
|
224
|
+
}
|
|
225
|
+
for (const c of readRoutes(run))
|
|
226
|
+
if (c.source === "climb" && c.from && (!f.role || c.role === f.role)) at(c.role, c.from).climbsFrom++;
|
|
227
|
+
for (const a of readAgentRuns(run)) {
|
|
228
|
+
if (f.role && a.role !== f.role) continue;
|
|
229
|
+
const s = agents.get(a.rung) ?? { rung: a.rung, runs: 0, totalTokens: 0, secs: 0 };
|
|
230
|
+
s.runs++;
|
|
231
|
+
s.totalTokens += a.totalTokens;
|
|
232
|
+
s.secs += a.secs ?? 0;
|
|
233
|
+
agents.set(a.rung, s);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
rungs: [...stats.values()].sort((a, b) => a.role.localeCompare(b.role) || a.rung.localeCompare(b.rung)),
|
|
238
|
+
agents: [...agents.values()].sort((a, b) => a.rung.localeCompare(b.rung)),
|
|
239
|
+
harness: harnessCosts(harnessRuns),
|
|
240
|
+
};
|
|
241
|
+
}
|