catherd-cli 1.2.1 → 1.4.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 +146 -36
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +45 -39
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +61 -18
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +49 -19
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/ids.ts +2 -2
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +89 -5
- package/src/domain/record.ts +3 -0
- package/src/domain/util.ts +3 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/knowledge-command.ts +50 -0
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +30 -10
- package/src/entry/tui/fixtures.ts +34 -10
- package/src/entry/tui/profile-tree.ts +10 -1
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +16 -3
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/paths.ts +3 -1
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +34 -7
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +49 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +8 -1
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +39 -7
- package/src/services/doctor-checks.ts +43 -3
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +107 -32
- package/src/services/finalize.ts +2 -1
- package/src/services/lane-service.ts +4 -10
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +117 -31
- package/src/services/profile-store.ts +126 -5
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +39 -4
- package/src/services/run-store.ts +21 -5
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +32 -7
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
|
@@ -5,7 +5,7 @@ import { CatherdError } from "../domain/errors.ts";
|
|
|
5
5
|
import { assertId } from "../domain/ids.ts";
|
|
6
6
|
import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
|
|
7
7
|
import type { OutcomeRow, RouteRow } from "../domain/route.ts";
|
|
8
|
-
import { slug } from "../domain/util.ts";
|
|
8
|
+
import { cell, slug } from "../domain/util.ts";
|
|
9
9
|
import { withFileLock } from "../infra/filelock.ts";
|
|
10
10
|
import { dataDir, repoDir, runsDir } from "../infra/paths.ts";
|
|
11
11
|
import {
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
} from "../infra/store.ts";
|
|
22
22
|
|
|
23
23
|
/** Spec §3.3: the Claude Code session that started a run; absent on 1.0 runs and outside Claude Code. */
|
|
24
|
-
const StartedBySchema = z.
|
|
24
|
+
const StartedBySchema = z.looseObject({
|
|
25
|
+
host: z.enum(["claude-code", "codex"]).default("claude-code"),
|
|
25
26
|
sessionId: z.string(),
|
|
26
27
|
hostSessionId: z.string().nullable(),
|
|
27
28
|
name: z.string().nullable(),
|
|
@@ -82,7 +83,12 @@ export function createRun(o: {
|
|
|
82
83
|
aLines: string[];
|
|
83
84
|
version: string;
|
|
84
85
|
now?: Date;
|
|
85
|
-
startedBy?:
|
|
86
|
+
startedBy?: {
|
|
87
|
+
host?: StartedBy["host"];
|
|
88
|
+
sessionId: string;
|
|
89
|
+
hostSessionId: string | null;
|
|
90
|
+
name: string | null;
|
|
91
|
+
} | null;
|
|
86
92
|
}): Run {
|
|
87
93
|
const now = o.now ?? new Date();
|
|
88
94
|
const root = runsDir(o.repo);
|
|
@@ -123,7 +129,7 @@ export function createRun(o: {
|
|
|
123
129
|
aLines: o.aLines,
|
|
124
130
|
createdAt: now.toISOString(),
|
|
125
131
|
catherdVersion: o.version,
|
|
126
|
-
...(o.startedBy ? { startedBy: o.startedBy } : {}),
|
|
132
|
+
...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
|
|
127
133
|
};
|
|
128
134
|
writeJsonAtomic(p.meta, meta);
|
|
129
135
|
return { id, dir, meta };
|
|
@@ -196,7 +202,7 @@ export function readRecords(run: Run): { records: RunRecord[]; corrupt: number }
|
|
|
196
202
|
if (!r.success) bad++;
|
|
197
203
|
else if (!seen.has(r.data.dispatchId)) {
|
|
198
204
|
seen.add(r.data.dispatchId);
|
|
199
|
-
records.push(r.data);
|
|
205
|
+
records.push(r.data.sessionId && !r.data.host ? { ...r.data, host: "claude-code" } : r.data);
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
208
|
return { records, corrupt: bad };
|
|
@@ -273,6 +279,16 @@ export function appendLedger(run: Run, row: string): void {
|
|
|
273
279
|
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
|
|
274
280
|
export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
|
|
275
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
|
|
284
|
+
* it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
|
|
285
|
+
*/
|
|
286
|
+
export function appendKnowledge(toplevel: string, at: Date, source: string, text: string): string {
|
|
287
|
+
const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
|
|
288
|
+
appendPrivate(knowledgeFile(toplevel), `${line}\n`);
|
|
289
|
+
return line;
|
|
290
|
+
}
|
|
291
|
+
|
|
276
292
|
const SERVER_OWNED = new Set([
|
|
277
293
|
"meta.json",
|
|
278
294
|
"state.md",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey } from "../domain/host.ts";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { budgetStatus } from "../domain/budget.ts";
|
|
@@ -98,7 +99,7 @@ export interface MilestoneDetail {
|
|
|
98
99
|
digest: string | null;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
const keyOf = (g: SessionGroup): string | null => g.session
|
|
102
|
+
const keyOf = (g: SessionGroup): string | null => (g.session ? sessionKey(g.session) : null);
|
|
102
103
|
|
|
103
104
|
/** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
|
|
104
105
|
const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
|
|
@@ -174,7 +175,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
|
|
|
174
175
|
try {
|
|
175
176
|
const b = budgetStatus(
|
|
176
177
|
spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
|
|
177
|
-
deps.profiles.forRepo(run.meta.repo).budget,
|
|
178
|
+
deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
|
|
178
179
|
);
|
|
179
180
|
budget = b?.fraction ?? null;
|
|
180
181
|
} catch {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey, type KnownHost } from "../domain/host.ts";
|
|
1
2
|
import { statSync } from "node:fs";
|
|
2
3
|
import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
|
|
3
4
|
import { type Run, runPaths } from "./run-store.ts";
|
|
@@ -14,6 +15,7 @@ import { readSessionRows } from "./sessions.ts";
|
|
|
14
15
|
|
|
15
16
|
/** A session as the runs page shows it: its name read live while it runs, else the last one recorded. */
|
|
16
17
|
export interface RunSession {
|
|
18
|
+
host: KnownHost;
|
|
17
19
|
sessionId: string;
|
|
18
20
|
hostSessionId: string | null;
|
|
19
21
|
name: string;
|
|
@@ -56,10 +58,11 @@ export function runActivity(run: Run): number {
|
|
|
56
58
|
/** The sessions each run has had, oldest first: its starter, then each one that took it over. */
|
|
57
59
|
function trailOf(
|
|
58
60
|
run: Run,
|
|
59
|
-
): { sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
|
|
61
|
+
): { host: KnownHost; sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
|
|
60
62
|
const rows = readSessionRows(run);
|
|
61
63
|
const s = run.meta.startedBy;
|
|
62
|
-
if (s && !rows.some((r) => r
|
|
64
|
+
if (s && !rows.some((r) => sessionKey(r) === sessionKey(s)))
|
|
65
|
+
return [{ ...s, at: run.meta.createdAt }, ...rows];
|
|
63
66
|
return rows;
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -70,21 +73,23 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
|
|
|
70
73
|
const trails = new Map(runs.map((r) => [r.id, trailOf(r)]));
|
|
71
74
|
for (const t of trails.values())
|
|
72
75
|
for (const row of t) {
|
|
73
|
-
const was = recorded.get(row
|
|
76
|
+
const was = recorded.get(sessionKey(row));
|
|
74
77
|
if (!was || row.at >= was.at)
|
|
75
|
-
recorded.set(row
|
|
78
|
+
recorded.set(sessionKey(row), {
|
|
76
79
|
name: row.name ?? was?.name ?? null,
|
|
77
80
|
host: row.hostSessionId ?? was?.host ?? null,
|
|
78
81
|
at: row.at,
|
|
79
82
|
});
|
|
80
83
|
}
|
|
81
84
|
const sessionOf = (id: string): RunSession => {
|
|
82
|
-
const
|
|
85
|
+
const [host, sessionId] = JSON.parse(id) as [KnownHost, string];
|
|
86
|
+
const live = host === "claude-code" ? liveSessionFile(sessionId, files) : null;
|
|
83
87
|
const rec = recorded.get(id);
|
|
84
88
|
return {
|
|
85
|
-
|
|
89
|
+
host,
|
|
90
|
+
sessionId,
|
|
86
91
|
hostSessionId: live?.hostSessionId ?? rec?.host ?? null,
|
|
87
|
-
name: live?.name ?? rec?.name ?? `session ${
|
|
92
|
+
name: live?.name ?? rec?.name ?? `session ${sessionId.slice(0, 8)}`,
|
|
88
93
|
live: live !== null,
|
|
89
94
|
};
|
|
90
95
|
};
|
|
@@ -101,13 +106,13 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
|
|
|
101
106
|
for (const run of runs) {
|
|
102
107
|
const at = runActivity(run);
|
|
103
108
|
const trail = trails.get(run.id) ?? [];
|
|
104
|
-
const starter = run.meta.startedBy
|
|
109
|
+
const starter = run.meta.startedBy ? sessionKey(run.meta.startedBy) : null;
|
|
105
110
|
// 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)
|
|
111
|
+
const owner = trail.at(-1) ? sessionKey(trail.at(-1)!) : starter;
|
|
107
112
|
const moved = owner !== starter && owner !== null;
|
|
108
113
|
// a run that lives on elsewhere counts, in a session it left, from when that session last took it
|
|
109
114
|
const tookAt = (id: string | null): number =>
|
|
110
|
-
Date.parse(trail.findLast((t) => t
|
|
115
|
+
Date.parse(trail.findLast((t) => sessionKey(t) === id)?.at ?? run.meta.createdAt) || 0;
|
|
111
116
|
add(
|
|
112
117
|
starter,
|
|
113
118
|
{
|
|
@@ -118,7 +123,7 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
|
|
|
118
123
|
},
|
|
119
124
|
moved ? tookAt(starter) : at,
|
|
120
125
|
);
|
|
121
|
-
for (const id of new Set(trail.map(
|
|
126
|
+
for (const id of new Set(trail.map(sessionKey)))
|
|
122
127
|
if (id !== starter)
|
|
123
128
|
add(
|
|
124
129
|
id,
|
|
@@ -146,7 +151,7 @@ export function sessionFacts(
|
|
|
146
151
|
run: Run,
|
|
147
152
|
files: SessionFile[] = readSessionFiles(),
|
|
148
153
|
): { session: RunSession | null; continuedIn: string | null } {
|
|
149
|
-
const starter = run.meta.startedBy
|
|
150
|
-
const own = groupRuns([run], files).find((g) => (g.session
|
|
154
|
+
const starter = run.meta.startedBy ? sessionKey(run.meta.startedBy) : null;
|
|
155
|
+
const own = groupRuns([run], files).find((g) => (g.session ? sessionKey(g.session) : null) === starter);
|
|
151
156
|
return { session: own?.session ?? null, continuedIn: own?.runs[0]?.continuedIn ?? null };
|
|
152
157
|
}
|
package/src/services/sessions.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey, type HostSessionRef } from "../domain/host.ts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { sessionFileFor } from "../infra/claude-session.ts";
|
|
3
4
|
import { withFileLock } from "../infra/filelock.ts";
|
|
@@ -6,27 +7,34 @@ import type { Deps } from "./ports.ts";
|
|
|
6
7
|
import { type Run, runPaths } from "./run-store.ts";
|
|
7
8
|
import { readNotes } from "./state.ts";
|
|
8
9
|
|
|
9
|
-
/** Spec §4: a
|
|
10
|
-
export
|
|
11
|
-
sessionId: string;
|
|
12
|
-
hostSessionId: string | null;
|
|
13
|
-
name: string | null;
|
|
14
|
-
}
|
|
10
|
+
/** Spec §4: a host session as catherd records it. */
|
|
11
|
+
export type SessionRef = HostSessionRef;
|
|
15
12
|
|
|
16
13
|
/**
|
|
17
14
|
* 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
|
|
15
|
+
* id in the environment goes stale after /clear), else the environment's. Null without known, consistent host identity.
|
|
19
16
|
*/
|
|
20
17
|
export function currentSession(deps: Deps): SessionRef | null {
|
|
18
|
+
if (deps.host.host === "unknown" || deps.host.conflict) return null;
|
|
19
|
+
if (deps.host.host === "codex") return deps.host.session;
|
|
21
20
|
const env = deps.session;
|
|
22
|
-
if (!env) return
|
|
21
|
+
if (!env) return deps.host.session;
|
|
23
22
|
const file = sessionFileFor(env);
|
|
24
23
|
const sessionId = file?.sessionId ?? env.sessionId;
|
|
25
24
|
if (!sessionId) return null;
|
|
26
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
host: "claude-code",
|
|
27
|
+
sessionId,
|
|
28
|
+
hostSessionId: file?.hostSessionId ?? env.hostSessionId,
|
|
29
|
+
name: file?.name ?? null,
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
const OwnerSchema = z.
|
|
33
|
+
const OwnerSchema = z.looseObject({
|
|
34
|
+
host: z.enum(["claude-code", "codex"]).default("claude-code"),
|
|
35
|
+
sessionId: z.string(),
|
|
36
|
+
since: z.string(),
|
|
37
|
+
});
|
|
30
38
|
export type Owner = z.infer<typeof OwnerSchema>;
|
|
31
39
|
|
|
32
40
|
/** The run's owner session (state.json `owner`), or null for a run no session has owned (1.0, a terminal). */
|
|
@@ -38,10 +46,12 @@ export function runOwner(run: Run): Owner | null {
|
|
|
38
46
|
/** Whether this process serves a session and that session owns the run now. */
|
|
39
47
|
export function ownsRun(deps: Deps, run: Run): boolean {
|
|
40
48
|
const me = currentSession(deps);
|
|
41
|
-
|
|
49
|
+
const owner = runOwner(run);
|
|
50
|
+
return me !== null && owner !== null && sessionKey(owner) === sessionKey(me);
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
const SessionRowSchema = z.looseObject({
|
|
54
|
+
host: z.enum(["claude-code", "codex"]).default("claude-code"),
|
|
45
55
|
sessionId: z.string(),
|
|
46
56
|
hostSessionId: z.string().nullable(),
|
|
47
57
|
name: z.string().nullable(),
|
|
@@ -59,8 +69,7 @@ export function readSessionRows(run: Run): SessionRow[] {
|
|
|
59
69
|
|
|
60
70
|
/**
|
|
61
71
|
* 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.
|
|
63
|
-
* Claude Code it changes nothing. Returns whether the owner changed.
|
|
72
|
+
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing. Returns whether the owner changed.
|
|
64
73
|
*/
|
|
65
74
|
export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
66
75
|
const me = currentSession(deps);
|
|
@@ -70,6 +79,7 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
|
70
79
|
const row = (since: string): void => {
|
|
71
80
|
ensureJsonlHeader(p.sessions, "sessions");
|
|
72
81
|
appendJsonl(p.sessions, {
|
|
82
|
+
host: me.host,
|
|
73
83
|
sessionId: me.sessionId,
|
|
74
84
|
hostSessionId: me.hostSessionId,
|
|
75
85
|
name: me.name,
|
|
@@ -78,14 +88,20 @@ export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
|
78
88
|
};
|
|
79
89
|
// the trail row goes in under the same lock: the trail's last row is the owner (session-view reads it so)
|
|
80
90
|
return withFileLock(p.stateJson, () => {
|
|
91
|
+
const live = currentSession(deps);
|
|
92
|
+
if (!live || sessionKey(live) !== sessionKey(me)) return false;
|
|
81
93
|
const notes = readNotes(run);
|
|
82
94
|
const owner = runOwner(run);
|
|
83
|
-
if (owner
|
|
95
|
+
if (owner && sessionKey(owner) === sessionKey(me)) {
|
|
84
96
|
// a crash between the state.json write and the append left the trail without its owner: repaired here
|
|
85
|
-
|
|
97
|
+
const last = readSessionRows(run).at(-1);
|
|
98
|
+
if (!last || sessionKey(last) !== sessionKey(me)) row(owner.since);
|
|
86
99
|
return false;
|
|
87
100
|
}
|
|
88
|
-
writeJsonAtomic(p.stateJson, {
|
|
101
|
+
writeJsonAtomic(p.stateJson, {
|
|
102
|
+
...notes,
|
|
103
|
+
owner: { ...owner, host: me.host, sessionId: me.sessionId, since: at },
|
|
104
|
+
});
|
|
89
105
|
row(at);
|
|
90
106
|
return true;
|
|
91
107
|
});
|
package/src/services/setup.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ADAPTER_IDS } from "../domain/ids.ts";
|
|
2
|
+
import { usedBackends } from "./doctor-backends.ts";
|
|
3
|
+
import type { HostContext } from "../domain/host.ts";
|
|
1
4
|
import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
|
2
5
|
import { basename, join } from "node:path";
|
|
3
6
|
import type { Issue } from "../domain/profile-rules.ts";
|
|
@@ -5,7 +8,14 @@ import { configDir } from "../infra/paths.ts";
|
|
|
5
8
|
import { type Refreshed, refreshDiscovery } from "./catalog-service.ts";
|
|
6
9
|
import type { Synced } from "./agent-links.ts";
|
|
7
10
|
import { activate, resetProfile, withProfilesLock } from "./profile-service.ts";
|
|
8
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
activeName,
|
|
13
|
+
configFile,
|
|
14
|
+
getProfile,
|
|
15
|
+
profilesDir,
|
|
16
|
+
projectsFile,
|
|
17
|
+
readProjects,
|
|
18
|
+
} from "./profile-store.ts";
|
|
9
19
|
import { ensurePrivateDir } from "../infra/store.ts";
|
|
10
20
|
|
|
11
21
|
/** True when profile `name` has a file (`default` exists as a name even before it has one). */
|
|
@@ -65,23 +75,34 @@ export interface InitResult {
|
|
|
65
75
|
/**
|
|
66
76
|
* Spec §8 `catherd init`, the setup half: moves 0.x files aside, writes the default profile (spec §7.2)
|
|
67
77
|
* unless a 1.0 one exists and `overwrite` is not set, makes it active and links its agents, and lists
|
|
68
|
-
*
|
|
69
|
-
*
|
|
78
|
+
* backend models, skipping unused headless Claude on a Codex host. Defaults that do not validate return
|
|
79
|
+
* errors without a write; the profile becomes active only when it has a file.
|
|
70
80
|
*/
|
|
71
|
-
export async function initSetup(
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
export async function initSetup(o: {
|
|
82
|
+
profile?: string;
|
|
83
|
+
repo?: string | null;
|
|
84
|
+
overwrite?: boolean;
|
|
85
|
+
now?: Date;
|
|
86
|
+
host: HostContext;
|
|
87
|
+
}): Promise<InitResult> {
|
|
74
88
|
const moved = moveLegacy(o.now);
|
|
75
|
-
const profile = o.profile ??
|
|
89
|
+
const profile = o.profile ?? activeName(o.repo);
|
|
76
90
|
let created = false;
|
|
77
91
|
let errors: Issue[] = [];
|
|
78
92
|
if (!hasProfileFile(profile) || o.overwrite === true) {
|
|
79
|
-
const saved = resetProfile(profile);
|
|
93
|
+
const saved = resetProfile(profile, o.host.host);
|
|
80
94
|
created = saved.saved;
|
|
81
95
|
errors = saved.errors;
|
|
82
96
|
}
|
|
83
97
|
const active = hasProfileFile(profile);
|
|
84
|
-
const
|
|
85
|
-
|
|
98
|
+
const repo =
|
|
99
|
+
o.profile === undefined && o.repo && readProjects().bindings[o.repo] !== undefined ? o.repo : null;
|
|
100
|
+
const synced = active ? activate(profile, repo, o.host.host) : null;
|
|
101
|
+
const used = usedBackends(active ? [getProfile(profile, o.host.host)] : []);
|
|
102
|
+
const backends =
|
|
103
|
+
o.host.host === "codex" && !used.has("claude-code")
|
|
104
|
+
? ADAPTER_IDS.filter((id) => id !== "claude-code")
|
|
105
|
+
: undefined;
|
|
106
|
+
const refreshed = await refreshDiscovery({ backends });
|
|
86
107
|
return { moved, profile, created, errors, active, synced, refreshed };
|
|
87
108
|
}
|
|
@@ -85,14 +85,22 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
|
|
|
85
85
|
const { id, effort } = splitSourceRung(row.rung);
|
|
86
86
|
const family = map.family(id);
|
|
87
87
|
if (!family) (unmatched[source] ??= new Set()).add(id);
|
|
88
|
-
|
|
88
|
+
// spec 1.3 §7.1: a family with no effort (Composer) has one rung, #default, whatever effort a source names
|
|
89
|
+
const effortless = family !== null && familyEfforts(family).length === 0;
|
|
90
|
+
const e = effortless ? "default" : (effort ?? (family ? defaultEffortOf(ctx.sources, family) : ""));
|
|
89
91
|
const key = `${map.key(family?.id ?? id)}#${e}`;
|
|
90
92
|
const field = `${source}.${row.field}`;
|
|
91
93
|
const at = table.get(field) ?? new Map<string, Keyed>();
|
|
92
94
|
table.set(field, at);
|
|
93
95
|
const had = at.get(key);
|
|
94
96
|
if (!had || row.value > had.row.value)
|
|
95
|
-
at.set(key, {
|
|
97
|
+
at.set(key, {
|
|
98
|
+
key,
|
|
99
|
+
family,
|
|
100
|
+
effort: e,
|
|
101
|
+
assumed: effort === null && family !== null && !effortless,
|
|
102
|
+
row,
|
|
103
|
+
});
|
|
96
104
|
}
|
|
97
105
|
const valuesOf = (f: FieldRef) =>
|
|
98
106
|
new Map([...(table.get(`${f.source}.${f.field}`)?.values() ?? [])].map((k) => [k.key, k.row.value]));
|
|
@@ -163,7 +171,9 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
|
|
|
163
171
|
const family = map.family(id);
|
|
164
172
|
if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
|
|
165
173
|
}
|
|
166
|
-
scores.push(
|
|
174
|
+
scores.push(
|
|
175
|
+
...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f)),
|
|
176
|
+
);
|
|
167
177
|
|
|
168
178
|
const { facts, warnings } = factsOf(raw, ctx, map);
|
|
169
179
|
return {
|
|
@@ -203,13 +213,16 @@ function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"]
|
|
|
203
213
|
* Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
|
|
204
214
|
* one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
|
|
205
215
|
* has one (the weaker on a tie). A sync spreads only its own values, and never onto a value the shipped file
|
|
206
|
-
* carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`.
|
|
216
|
+
* carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`. A family that
|
|
217
|
+
* one backend runs without an effort (Cursor's bare slug, spec 1.3 §7.1) also gets `#default`, carried from the
|
|
218
|
+
* value nearest `defaultEffort(family)`.
|
|
207
219
|
*/
|
|
208
220
|
export function adjacent(
|
|
209
221
|
families: Family[],
|
|
210
222
|
direct: Score[],
|
|
211
223
|
shipped: ReadonlySet<string>,
|
|
212
224
|
now: number,
|
|
225
|
+
defaultEffort: (f: Family) => string = () => "high",
|
|
213
226
|
): Score[] {
|
|
214
227
|
const out: Score[] = [];
|
|
215
228
|
for (const f of families)
|
|
@@ -222,9 +235,11 @@ export function adjacent(
|
|
|
222
235
|
if (!had || outranks(s, had, now)) byEffort.set(effort, s);
|
|
223
236
|
}
|
|
224
237
|
if (byEffort.size === 0) continue;
|
|
225
|
-
|
|
238
|
+
const efforts = familyEfforts(f);
|
|
239
|
+
const bare = efforts.length > 0 && Object.values(f.on).some((o) => o && o.efforts.length === 0);
|
|
240
|
+
for (const e of bare ? [...efforts, "default"] : efforts) {
|
|
226
241
|
if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
|
|
227
|
-
const near = nearestEffort(e, [...byEffort.keys()]);
|
|
242
|
+
const near = nearestEffort(e === "default" ? defaultEffort(f) : e, [...byEffort.keys()]);
|
|
228
243
|
const from = near ? byEffort.get(near) : undefined;
|
|
229
244
|
if (!near || !from) continue;
|
|
230
245
|
out.push({
|
|
@@ -261,7 +276,8 @@ function factsOf(
|
|
|
261
276
|
for (const f of ctx.models.families) {
|
|
262
277
|
const x: FamilyFacts = { on: {}, speed: {} };
|
|
263
278
|
const vendor = (f as { vendor?: unknown }).vendor;
|
|
264
|
-
const own =
|
|
279
|
+
const own =
|
|
280
|
+
md && typeof vendor === "string" ? modelsDevFacts(md, vendor, modelsDevId(md, vendor, f, map)) : null;
|
|
265
281
|
if (own?.price) x.price = own.price;
|
|
266
282
|
if (own && own.toolUse !== null && own.imageIn !== null && own.reasoning !== null)
|
|
267
283
|
x.capabilities = { toolUse: own.toolUse, imageIn: own.imageIn, reasoning: own.reasoning };
|
|
@@ -290,6 +306,15 @@ function factsOf(
|
|
|
290
306
|
return { facts, warnings };
|
|
291
307
|
}
|
|
292
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The id of `vendor`'s models.dev model that is `f`: its own id, else the first that maps to it (spec 1.3 §7.1:
|
|
311
|
+
* `gemini-3-8-flash` is Google's `gemini-3.8-flash`, `gemini-3-1-pro` its aliased `gemini-3.1-pro-preview`).
|
|
312
|
+
*/
|
|
313
|
+
function modelsDevId(md: unknown, vendor: string, f: Family, map: IdMapper): string {
|
|
314
|
+
const ids = Object.keys((md as Record<string, { models?: object }>)[vendor]?.models ?? {});
|
|
315
|
+
return ids.includes(f.id) ? f.id : (ids.find((id) => map.family(id) === f) ?? f.id);
|
|
316
|
+
}
|
|
317
|
+
|
|
293
318
|
/** The first entry of `list` whose id maps to `f` (OpenRouter's `:batch` variants never do). */
|
|
294
319
|
const entryFor = <T extends { id: string }>(list: T[], f: Family, map: IdMapper): T | undefined =>
|
|
295
320
|
list.find((m) => map.family(m.id) === f);
|
package/src/services/summary.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { HostContext } from "../domain/host.ts";
|
|
2
|
+
import type { QueueCapability } from "../infra/codex-queue.ts";
|
|
3
|
+
import { inspectionHost, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
|
|
1
4
|
import { resolve } from "node:path";
|
|
2
5
|
import { type BudgetStatus, budgetStatus } from "../domain/budget.ts";
|
|
3
6
|
import { isCatherdError } from "../domain/errors.ts";
|
|
@@ -7,7 +10,7 @@ import { nonBlankLines, readJsonl } from "../infra/store.ts";
|
|
|
7
10
|
import { spendOf } from "./budget.ts";
|
|
8
11
|
import { latestVerifierStep, type VerifierStep } from "./gate-service.ts";
|
|
9
12
|
import { type OpenQuestion, openQuestions } from "./questions.ts";
|
|
10
|
-
import { type DispatchState, liveDispatches } from "./dispatches.ts";
|
|
13
|
+
import { type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
11
14
|
import { type RunSession, sessionFacts } from "./session-view.ts";
|
|
12
15
|
import type { Deps } from "./ports.ts";
|
|
13
16
|
import {
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
} from "./run-store.ts";
|
|
22
25
|
|
|
23
26
|
export interface RunSummary {
|
|
27
|
+
delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
|
|
24
28
|
id: string;
|
|
25
29
|
/** spec 1.1 §8: the owner questions not answered yet, listed first */
|
|
26
30
|
questions: OpenQuestion[];
|
|
@@ -51,17 +55,28 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
51
55
|
const now = deps.now();
|
|
52
56
|
const { records, corrupt } = readRecords(run);
|
|
53
57
|
const live = liveDispatches(run, now);
|
|
58
|
+
const recorded = new Set(records.map((r) => r.dispatchId));
|
|
54
59
|
const agents = readAgentRuns(run);
|
|
55
60
|
const jev = readJsonl<{ source?: string }>(runPaths(run.dir).jev).rows;
|
|
56
61
|
const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
|
|
57
62
|
let budget: BudgetStatus | null = null;
|
|
58
63
|
const warnings = corrupt ? [`runs.jsonl: skipped ${corrupt} unreadable row(s)`] : [];
|
|
59
64
|
try {
|
|
60
|
-
budget = budgetStatus(
|
|
65
|
+
budget = budgetStatus(
|
|
66
|
+
spendOf(run, records, live, now),
|
|
67
|
+
deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
|
|
68
|
+
);
|
|
61
69
|
} catch (e) {
|
|
62
70
|
warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
|
|
63
71
|
}
|
|
64
72
|
return {
|
|
73
|
+
delivery: listDispatches(run).flatMap((d) =>
|
|
74
|
+
inspectDeliveries(run, d, recorded.has(d.admit.dispatchId)).map((inspection) => ({
|
|
75
|
+
name: d.admit.name,
|
|
76
|
+
dispatchId: d.admit.dispatchId,
|
|
77
|
+
...inspection,
|
|
78
|
+
})),
|
|
79
|
+
),
|
|
65
80
|
id: run.id,
|
|
66
81
|
questions: openQuestions(run),
|
|
67
82
|
title: run.meta.title,
|
|
@@ -110,13 +125,33 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
110
125
|
export function status(
|
|
111
126
|
deps: Deps,
|
|
112
127
|
runId?: string,
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
queue: QueueCapability | null = null,
|
|
129
|
+
): {
|
|
130
|
+
version: string;
|
|
131
|
+
runs: RunSummary[];
|
|
132
|
+
warnings: string[];
|
|
133
|
+
host: HostContext;
|
|
134
|
+
queue: QueueCapability | null;
|
|
135
|
+
} {
|
|
136
|
+
if (runId)
|
|
137
|
+
return {
|
|
138
|
+
host: inspectionHost(deps.host),
|
|
139
|
+
queue,
|
|
140
|
+
version: deps.version,
|
|
141
|
+
runs: [summarizeRun(deps, findRun(runId))],
|
|
142
|
+
warnings: [],
|
|
143
|
+
};
|
|
115
144
|
const { runs, corrupt } = listRuns();
|
|
116
145
|
const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
|
|
117
146
|
const all = runs.map((r) => summarizeRun(deps, r));
|
|
118
147
|
const live = all.filter((s) => s.live.length > 0);
|
|
119
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
host: inspectionHost(deps.host),
|
|
150
|
+
queue,
|
|
151
|
+
version: deps.version,
|
|
152
|
+
runs: live.length ? live : all.slice(0, 1),
|
|
153
|
+
warnings,
|
|
154
|
+
};
|
|
120
155
|
}
|
|
121
156
|
|
|
122
157
|
export interface RungStats {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import { type Dim, scoresOf, rungInfo } from "../domain/catalog.ts";
|
|
2
3
|
import { CatherdError } from "../domain/errors.ts";
|
|
3
4
|
import { tryParseRung } from "../domain/ids.ts";
|
|
@@ -21,7 +22,10 @@ export interface LeftOnStandIn {
|
|
|
21
22
|
* inferred stand-in, or be left with no value at all, once the user's treat-likes for `removed` (canonical
|
|
22
23
|
* rungs, or all of them) are gone. Never removed silently.
|
|
23
24
|
*/
|
|
24
|
-
export function leftOnStandIns(
|
|
25
|
+
export function leftOnStandIns(
|
|
26
|
+
removed: string[] | "all",
|
|
27
|
+
host: OrchestrationHost = "unknown",
|
|
28
|
+
): LeftOnStandIn[] {
|
|
25
29
|
const cur = readOverride();
|
|
26
30
|
const treatLike = Object.fromEntries(
|
|
27
31
|
Object.entries(cur.treatLike).filter(([r]) => removed !== "all" && !removed.includes(r)),
|
|
@@ -29,12 +33,18 @@ export function leftOnStandIns(removed: string[] | "all"): LeftOnStandIn[] {
|
|
|
29
33
|
const gone = removed === "all" ? Object.keys(cur.treatLike) : removed;
|
|
30
34
|
const c = loadCatalog({ timings: false, override: { ...cur, treatLike } });
|
|
31
35
|
const out: LeftOnStandIn[] = [];
|
|
36
|
+
// an unknown host resolves no omitted architect/verifier rungs: name what either host would run
|
|
37
|
+
const hosts: OrchestrationHost[] = host === "unknown" ? ["claude-code", "codex"] : [host];
|
|
32
38
|
for (const profile of listProfiles()) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const rungs = new Set(
|
|
40
|
+
hosts.flatMap((h) => {
|
|
41
|
+
const p = getProfile(profile, h);
|
|
42
|
+
return [
|
|
43
|
+
...ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs),
|
|
44
|
+
...Object.values(p.failover),
|
|
45
|
+
];
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
38
48
|
for (const rung of rungs) {
|
|
39
49
|
if (!tryParseRung(rung)) continue;
|
|
40
50
|
const canonical = rungInfo(c, rung).canonical;
|
|
@@ -65,6 +75,7 @@ export function suggestFor(rung: string): { canonical: string; lacking: Dim[]; s
|
|
|
65
75
|
*/
|
|
66
76
|
export async function clearTreatLike(
|
|
67
77
|
rung: string,
|
|
78
|
+
host: OrchestrationHost = "unknown",
|
|
68
79
|
): Promise<{ rung: string; like: string; left: LeftOnStandIn[] }> {
|
|
69
80
|
const canonical = canonicalRung(rung);
|
|
70
81
|
const like = readOverride().treatLike[canonical];
|
|
@@ -72,13 +83,15 @@ export async function clearTreatLike(
|
|
|
72
83
|
throw new CatherdError("E_INPUT_INVALID", `${canonical} has no treat-like of yours to clear`, {
|
|
73
84
|
fix: "catherd catalog list shows each rung's treat-like; a shipped one is not yours to clear",
|
|
74
85
|
});
|
|
75
|
-
const left = leftOnStandIns([canonical]);
|
|
86
|
+
const left = leftOnStandIns([canonical], host);
|
|
76
87
|
await removeTreatLikes([canonical]);
|
|
77
88
|
return { rung: canonical, like, left };
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
/** Spec 1.2 §6.4 `--reset`: removes every treat-like of the user's, naming first the rungs left on a stand-in. */
|
|
81
|
-
export async function resetTreatLikes(
|
|
82
|
-
|
|
92
|
+
export async function resetTreatLikes(
|
|
93
|
+
host: OrchestrationHost = "unknown",
|
|
94
|
+
): Promise<{ removed: [string, string][]; left: LeftOnStandIn[] }> {
|
|
95
|
+
const left = leftOnStandIns("all", host);
|
|
83
96
|
return { removed: await removeTreatLikes("all"), left };
|
|
84
97
|
}
|