catherd-cli 0.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/LICENSE +21 -0
- package/README.md +83 -0
- package/catalog/catalog.json +112 -0
- package/catalog/models-dev.json +56453 -0
- package/package.json +52 -0
- package/plugin/.claude-plugin/plugin.json +13 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/catherd-setup.md +8 -0
- package/plugin/commands/catherd.md +10 -0
- package/plugin/skills/catherd/SKILL.md +301 -0
- package/plugin/skills/catherd-setup/SKILL.md +65 -0
- package/src/cli.ts +28 -0
- package/src/core/codex-events.ts +49 -0
- package/src/core/codex.ts +166 -0
- package/src/core/harness.ts +89 -0
- package/src/core/lanes.ts +67 -0
- package/src/core/lock.ts +111 -0
- package/src/core/opencode.ts +207 -0
- package/src/core/reconcile.ts +26 -0
- package/src/core/reply.ts +54 -0
- package/src/core/runstore.ts +163 -0
- package/src/core/spawn.ts +89 -0
- package/src/core/status.ts +193 -0
- package/src/files.ts +19 -0
- package/src/mcp/commands.ts +7 -0
- package/src/mcp/dispatch.ts +154 -0
- package/src/mcp/lane-tools.ts +200 -0
- package/src/mcp/out.ts +6 -0
- package/src/mcp/preflight.ts +69 -0
- package/src/mcp/run-tools.ts +121 -0
- package/src/mcp/runs.ts +118 -0
- package/src/mcp/server.ts +26 -0
- package/src/mcp/setup-tools.ts +175 -0
- package/src/paths.ts +20 -0
- package/src/profile/agents.ts +118 -0
- package/src/profile/profile.ts +228 -0
- package/src/profile/role-prompts.ts +129 -0
- package/src/routing/catalog.ts +227 -0
- package/src/routing/commands.ts +22 -0
- package/src/routing/jev.ts +181 -0
- package/src/routing/route.ts +155 -0
- package/src/routing/select.ts +90 -0
- package/src/tui/backends.ts +57 -0
- package/src/tui/commands.ts +51 -0
- package/src/tui/editor.tsx +221 -0
- package/src/tui/init.tsx +221 -0
- package/src/tui/matrix-model.ts +211 -0
- package/src/tui/matrix.tsx +350 -0
- package/src/tui/profiles.ts +38 -0
- package/src/tui/theme.ts +140 -0
- package/src/tui/ui.tsx +104 -0
- package/src/tui/watch-model.ts +83 -0
- package/src/tui/watch.tsx +258 -0
- package/src/types.ts +184 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { ReplyStatus } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
const STATUS = /^STATUS:\s*(complete|partial|blocked|refused)\s*(?:—|–|-)\s*(.*)$/;
|
|
6
|
+
|
|
7
|
+
/** Dirs never worth hashing, even under an owned path. */
|
|
8
|
+
const SKIP = new Set(["node_modules", ".git", "dist", ".next", "coverage"]);
|
|
9
|
+
|
|
10
|
+
export function parseReplyStatus(reply: string): { status: ReplyStatus | null; why: string | null } {
|
|
11
|
+
const last = reply.trimEnd().split("\n").at(-1)?.trim() ?? "";
|
|
12
|
+
const m = STATUS.exec(last);
|
|
13
|
+
return m ? { status: m[1] as ReplyStatus, why: (m[2] ?? "").trim() } : { status: null, why: null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hashFile(path: string): string {
|
|
17
|
+
return new Bun.CryptoHasher("sha1").update(readFileSync(path)).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Owned paths are matched literally: walked with node:fs, never interpreted as glob patterns. */
|
|
21
|
+
function walk(root: string, dir: string, out: Map<string, string>): void {
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (SKIP.has(entry.name)) continue;
|
|
24
|
+
const full = join(dir, entry.name);
|
|
25
|
+
if (entry.isDirectory()) walk(root, full, out);
|
|
26
|
+
else if (entry.isFile()) out.set(relative(root, full), hashFile(full));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function snapshotOwned(repo: string, owned: string[]): Map<string, string> {
|
|
31
|
+
const m = new Map<string, string>();
|
|
32
|
+
for (const o of owned) {
|
|
33
|
+
const full = join(repo, o);
|
|
34
|
+
if (!existsSync(full)) {
|
|
35
|
+
m.set(o, "<absent>");
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (statSync(full).isDirectory()) walk(repo, full, m);
|
|
39
|
+
else m.set(o, hashFile(full));
|
|
40
|
+
}
|
|
41
|
+
return m;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function changedSince(repo: string, before: Map<string, string>, owned: string[]): string[] {
|
|
45
|
+
const after = snapshotOwned(repo, owned);
|
|
46
|
+
const changed = new Set<string>();
|
|
47
|
+
for (const [p, h] of after) {
|
|
48
|
+
if (before.get(p) !== h && !(h === "<absent>" && !before.has(p))) changed.add(p);
|
|
49
|
+
}
|
|
50
|
+
for (const [p, h] of before) {
|
|
51
|
+
if (!after.has(p) && h !== "<absent>") changed.add(p);
|
|
52
|
+
}
|
|
53
|
+
return [...changed].sort();
|
|
54
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { dataDir, runsRoot } from "../paths.ts";
|
|
13
|
+
import type { Role, RungId, RunMeta, RunRecord, StateSnapshot } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
export interface Run {
|
|
16
|
+
dir: string;
|
|
17
|
+
id: string;
|
|
18
|
+
meta: RunMeta;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Dirs under the data root that are catherd's own, not a repo's runs. */
|
|
22
|
+
const NOT_REPOS = new Set(["locks", "codex-home", "opencode-home"]);
|
|
23
|
+
|
|
24
|
+
const slug = (s: string) =>
|
|
25
|
+
s
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
28
|
+
.replace(/^-|-$/g, "")
|
|
29
|
+
.slice(0, 40) || "run";
|
|
30
|
+
|
|
31
|
+
const stamp = (d: Date) => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
32
|
+
|
|
33
|
+
export function createRun(repo: string, title: string, aLines: string[]): Run {
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const id = `${stamp(now)}-${slug(title)}`;
|
|
36
|
+
const dir = join(runsRoot(repo), id);
|
|
37
|
+
mkdirSync(join(dir, "lanes"), { recursive: true });
|
|
38
|
+
mkdirSync(join(dir, "roles"), { recursive: true });
|
|
39
|
+
mkdirSync(join(dir, "shots"), { recursive: true });
|
|
40
|
+
const meta: RunMeta = { id, repo, title, aLines, createdAt: now.toISOString() };
|
|
41
|
+
writeFileSync(join(dir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`);
|
|
42
|
+
writeFileSync(join(dir, "ledger.md"), "milestone | what | commit | minutes | evidence\n");
|
|
43
|
+
writeFileSync(join(dir, "state.md"), `# ${title}\n\n${aLines.join("\n")}\n\nNext: plan the milestones\n`);
|
|
44
|
+
return { dir, id, meta };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openRun(repo: string, id: string): Run {
|
|
48
|
+
const dir = join(runsRoot(repo), id);
|
|
49
|
+
const meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")) as RunMeta;
|
|
50
|
+
return { dir, id, meta };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function listRuns(): Run[] {
|
|
54
|
+
const root = dataDir();
|
|
55
|
+
if (!existsSync(root)) return [];
|
|
56
|
+
const runs: Run[] = [];
|
|
57
|
+
for (const repo of readdirSync(root, { withFileTypes: true })) {
|
|
58
|
+
if (!repo.isDirectory() || NOT_REPOS.has(repo.name)) continue;
|
|
59
|
+
for (const run of readdirSync(join(root, repo.name), { withFileTypes: true })) {
|
|
60
|
+
const dir = join(root, repo.name, run.name);
|
|
61
|
+
if (!run.isDirectory() || !existsSync(join(dir, "meta.json"))) continue;
|
|
62
|
+
const meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")) as RunMeta;
|
|
63
|
+
runs.push({ dir, id: meta.id, meta });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return runs.sort((a, b) => b.meta.createdAt.localeCompare(a.meta.createdAt));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rolePaths(dir: string, name: string) {
|
|
70
|
+
const base = join(dir, "roles", name);
|
|
71
|
+
return {
|
|
72
|
+
brief: `${base}.md`,
|
|
73
|
+
fix: `${base}.fix.md`,
|
|
74
|
+
out: `${base}.out`,
|
|
75
|
+
jsonl: `${base}.jsonl`,
|
|
76
|
+
err: `${base}.err`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Moves the latest round's out/jsonl/err to <name>.r<k>.* so the new round starts clean. */
|
|
81
|
+
export function rotateRound(dir: string, name: string): void {
|
|
82
|
+
const p = rolePaths(dir, name);
|
|
83
|
+
const current = [p.out, p.jsonl, p.err].filter((f) => existsSync(f));
|
|
84
|
+
if (current.length === 0) return;
|
|
85
|
+
let k = 1;
|
|
86
|
+
while (["out", "jsonl", "err"].some((x) => existsSync(join(dir, "roles", `${name}.r${k}.${x}`)))) k++;
|
|
87
|
+
for (const x of ["out", "jsonl", "err"] as const) {
|
|
88
|
+
if (existsSync(p[x])) renameSync(p[x], join(dir, "roles", `${name}.r${k}.${x}`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function appendJsonl(file: string, value: unknown): void {
|
|
93
|
+
appendFileSync(file, `${JSON.stringify(value)}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readJsonl<T>(file: string): T[] {
|
|
97
|
+
if (!existsSync(file)) return [];
|
|
98
|
+
return readFileSync(file, "utf8")
|
|
99
|
+
.split("\n")
|
|
100
|
+
.filter((l) => l.trim())
|
|
101
|
+
.map((l) => JSON.parse(l) as T);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const appendRunRecord = (dir: string, r: RunRecord) => appendJsonl(join(dir, "runs.jsonl"), r);
|
|
105
|
+
export const readRunRecords = (dir: string) => readJsonl<RunRecord>(join(dir, "runs.jsonl"));
|
|
106
|
+
|
|
107
|
+
export function writeState(dir: string, s: StateSnapshot): void {
|
|
108
|
+
const lines = [
|
|
109
|
+
`HEAD ${s.head}`,
|
|
110
|
+
"",
|
|
111
|
+
"Dirty:",
|
|
112
|
+
...(s.dirty.length ? s.dirty.map((d) => `- ${d.path}${d.owner ? ` (${d.owner})` : ""}`) : ["- none"]),
|
|
113
|
+
"",
|
|
114
|
+
"Running:",
|
|
115
|
+
...(s.running.length
|
|
116
|
+
? s.running.map(
|
|
117
|
+
(r) => `- ${r.name} · ${r.rung} · thread ${r.thread ?? "new"} · since ${r.since} · ${r.brief}`,
|
|
118
|
+
)
|
|
119
|
+
: ["- none"]),
|
|
120
|
+
"",
|
|
121
|
+
`Last check: ${s.lastCheck ?? "none"}`,
|
|
122
|
+
"",
|
|
123
|
+
`Next: ${s.next}`,
|
|
124
|
+
];
|
|
125
|
+
const title = readFileSync(join(dir, "state.md"), "utf8").split("\n")[0] ?? "# run";
|
|
126
|
+
writeFileSync(join(dir, "state.md"), `${title}\n\n${lines.join("\n")}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function appendLedger(dir: string, row: string): void {
|
|
130
|
+
appendFileSync(join(dir, "ledger.md"), `${row}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface LiveMarker {
|
|
134
|
+
name: string;
|
|
135
|
+
role: Role;
|
|
136
|
+
backend: "codex" | "opencode";
|
|
137
|
+
rung: RungId;
|
|
138
|
+
pid: number;
|
|
139
|
+
thread: string | null;
|
|
140
|
+
startedAt: string;
|
|
141
|
+
cwd: string;
|
|
142
|
+
ownedFiles: string[];
|
|
143
|
+
isolated: boolean;
|
|
144
|
+
before: Record<string, string>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const livePath = (dir: string, name: string) => join(dir, "roles", `${name}.live.json`);
|
|
148
|
+
|
|
149
|
+
export function writeLive(dir: string, m: LiveMarker): void {
|
|
150
|
+
writeFileSync(livePath(dir, m.name), JSON.stringify(m));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function clearLive(dir: string, name: string): void {
|
|
154
|
+
rmSync(livePath(dir, name), { force: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readLive(dir: string): LiveMarker[] {
|
|
158
|
+
const roles = join(dir, "roles");
|
|
159
|
+
if (!existsSync(roles)) return [];
|
|
160
|
+
return readdirSync(roles)
|
|
161
|
+
.filter((f) => f.endsWith(".live.json"))
|
|
162
|
+
.map((f) => JSON.parse(readFileSync(join(roles, f), "utf8")) as LiveMarker);
|
|
163
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface ChildOpts {
|
|
4
|
+
cwd: string;
|
|
5
|
+
env: Record<string, string | undefined>;
|
|
6
|
+
/** file to feed on stdin; stdin is /dev/null without it */
|
|
7
|
+
stdinFile?: string;
|
|
8
|
+
stdoutFile: string;
|
|
9
|
+
stderrFile: string;
|
|
10
|
+
/** called with each batch of new stdout lines, every pollMs */
|
|
11
|
+
onLines?: (lines: string[]) => void;
|
|
12
|
+
pollMs?: number;
|
|
13
|
+
/** called every tickMs while the child runs */
|
|
14
|
+
onTick?: () => void;
|
|
15
|
+
tickMs?: number;
|
|
16
|
+
/** called once, right after the child starts */
|
|
17
|
+
onSpawn?: (c: { pid: number; kill: () => void }) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChildResult {
|
|
21
|
+
code: number | null;
|
|
22
|
+
pid: number | null;
|
|
23
|
+
signal: NodeJS.Signals | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readLines(file: string): string[] {
|
|
27
|
+
if (!existsSync(file)) return [];
|
|
28
|
+
return readFileSync(file, "utf8")
|
|
29
|
+
.split("\n")
|
|
30
|
+
.filter((l) => l.trim());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Kills the whole process group a detached child leads (pgid === pid on POSIX). */
|
|
34
|
+
function killGroup(pid: number): void {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-pid, "SIGTERM");
|
|
37
|
+
} catch {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, "SIGTERM");
|
|
40
|
+
} catch {
|
|
41
|
+
// already gone
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The child gets raw file descriptors, not a pipe through this process, so no stream passes
|
|
48
|
+
* through catherd: with `detached: true` it starts its own process group (POSIX `setsid()`),
|
|
49
|
+
* outlives catherd, and `reconcileLive` reads its files after a restart.
|
|
50
|
+
*/
|
|
51
|
+
export async function runChild(cmd: string, args: string[], o: ChildOpts): Promise<ChildResult> {
|
|
52
|
+
if (!Bun.which(cmd, { PATH: o.env?.PATH ?? process.env.PATH ?? "" })) {
|
|
53
|
+
throw new Error(`catherd: ${cmd} is not on PATH; install it, or untick its models in the profile`);
|
|
54
|
+
}
|
|
55
|
+
const stdin = o.stdinFile ? openSync(o.stdinFile, "r") : "ignore";
|
|
56
|
+
const stdout = openSync(o.stdoutFile, "w");
|
|
57
|
+
const stderr = openSync(o.stderrFile, "w");
|
|
58
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
59
|
+
cwd: o.cwd,
|
|
60
|
+
env: o.env,
|
|
61
|
+
stdin,
|
|
62
|
+
stdout,
|
|
63
|
+
stderr,
|
|
64
|
+
detached: true,
|
|
65
|
+
});
|
|
66
|
+
for (const fd of [stdin, stdout, stderr]) if (typeof fd === "number") closeSync(fd);
|
|
67
|
+
const pid = proc.pid;
|
|
68
|
+
if (pid) o.onSpawn?.({ pid, kill: () => killGroup(pid) });
|
|
69
|
+
let seen = 0;
|
|
70
|
+
const drain = () => {
|
|
71
|
+
const lines = readLines(o.stdoutFile);
|
|
72
|
+
if (lines.length > seen) {
|
|
73
|
+
o.onLines?.(lines.slice(seen));
|
|
74
|
+
seen = lines.length;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const poll = o.onLines ? setInterval(drain, o.pollMs ?? 1000) : null;
|
|
78
|
+
const tick = o.onTick ? setInterval(o.onTick, o.tickMs ?? 60_000) : null;
|
|
79
|
+
let code: number | null;
|
|
80
|
+
try {
|
|
81
|
+
code = await proc.exited;
|
|
82
|
+
} finally {
|
|
83
|
+
if (poll) clearInterval(poll);
|
|
84
|
+
if (tick) clearInterval(tick);
|
|
85
|
+
}
|
|
86
|
+
if (o.onLines) drain();
|
|
87
|
+
if (!pid) throw new Error(`catherd: could not start ${cmd}`);
|
|
88
|
+
return { code, pid, signal: proc.signalCode ?? null };
|
|
89
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { activeProfileName, loadProfile } from "../profile/profile.ts";
|
|
4
|
+
import type { Profile, Role, RungId, Tokens } from "../types.ts";
|
|
5
|
+
import { formatHarness, type HarnessCost, harnessCosts } from "./harness.ts";
|
|
6
|
+
import { readRoutes } from "./lanes.ts";
|
|
7
|
+
import { reconcileLive } from "./reconcile.ts";
|
|
8
|
+
import { listRuns, readJsonl, readRunRecords, type Run } from "./runstore.ts";
|
|
9
|
+
|
|
10
|
+
export type RunTotals = {
|
|
11
|
+
runs: number;
|
|
12
|
+
ok: number;
|
|
13
|
+
notOk: string[];
|
|
14
|
+
secs: number;
|
|
15
|
+
tokens: Tokens;
|
|
16
|
+
costUsd: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface RunSummary {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
stateTail: string[];
|
|
24
|
+
live: { name: string; rung: RungId; secs: number; pid: number }[];
|
|
25
|
+
totals: RunTotals;
|
|
26
|
+
jev: { decisions: number; fallbacks: number };
|
|
27
|
+
milestones: string[];
|
|
28
|
+
harness: HarnessCost[];
|
|
29
|
+
/** spec §11b: this run's spend against the active profile's budget, or null with no budget set */
|
|
30
|
+
budget: BudgetStatus | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BudgetStatus {
|
|
34
|
+
/** the highest spent/cap ratio across the caps the profile set; ≥ 1 means exhausted */
|
|
35
|
+
fraction: number;
|
|
36
|
+
minutes?: { spent: number; cap: number };
|
|
37
|
+
tokens?: { spent: number; cap: number };
|
|
38
|
+
usd?: { spent: number; cap: number };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function budgetStatus(totals: RunTotals, budget: Profile["budget"] | undefined): BudgetStatus | null {
|
|
42
|
+
if (!budget) return null;
|
|
43
|
+
const b: BudgetStatus = { fraction: 0 };
|
|
44
|
+
const consider = (spent: number, cap: number | undefined, key: "minutes" | "tokens" | "usd") => {
|
|
45
|
+
if (cap === undefined) return;
|
|
46
|
+
b[key] = { spent, cap };
|
|
47
|
+
b.fraction = Math.max(b.fraction, cap > 0 ? spent / cap : spent > 0 ? 1 : 0);
|
|
48
|
+
};
|
|
49
|
+
consider(totals.secs / 60, budget.minutes, "minutes");
|
|
50
|
+
consider(totals.tokens.input + totals.tokens.output, budget.tokens, "tokens");
|
|
51
|
+
consider(totals.costUsd, budget.usd, "usd");
|
|
52
|
+
return b;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatBudget(b: BudgetStatus): string {
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
if (b.minutes) parts.push(`${Math.round(b.minutes.spent)}/${b.minutes.cap} min`);
|
|
58
|
+
if (b.tokens) parts.push(`${b.tokens.spent}/${b.tokens.cap} tokens`);
|
|
59
|
+
if (b.usd) parts.push(`$${b.usd.spent.toFixed(2)}/$${b.usd.cap.toFixed(2)}`);
|
|
60
|
+
return `${parts.join(" · ")} (${Math.round(b.fraction * 100)}%)`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The active profile's budget for this run's repo, or undefined when there is none or the profile fails to load. */
|
|
64
|
+
function budgetFor(run: Run): Profile["budget"] | undefined {
|
|
65
|
+
try {
|
|
66
|
+
return loadProfile(activeProfileName(run.meta.repo)).budget;
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const lines = (file: string) =>
|
|
73
|
+
existsSync(file)
|
|
74
|
+
? readFileSync(file, "utf8")
|
|
75
|
+
.split("\n")
|
|
76
|
+
.filter((l) => l.trim())
|
|
77
|
+
: [];
|
|
78
|
+
|
|
79
|
+
export function summarizeRun(run: Run): RunSummary {
|
|
80
|
+
const { stillRunning } = reconcileLive(run.dir);
|
|
81
|
+
const recs = readRunRecords(run.dir);
|
|
82
|
+
const jev = readJsonl<{ source?: string }>(join(run.dir, "jev.jsonl"));
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const totals: RunTotals = {
|
|
85
|
+
runs: recs.length,
|
|
86
|
+
ok: recs.filter((r) => r.status === "ok").length,
|
|
87
|
+
notOk: recs.filter((r) => r.status !== "ok").map((r) => `${r.name} (${r.status})`),
|
|
88
|
+
secs: recs.reduce((n, r) => n + r.secs, 0),
|
|
89
|
+
tokens: {
|
|
90
|
+
input: recs.reduce((n, r) => n + r.tokens.input, 0),
|
|
91
|
+
cached: recs.reduce((n, r) => n + r.tokens.cached, 0),
|
|
92
|
+
output: recs.reduce((n, r) => n + r.tokens.output, 0),
|
|
93
|
+
},
|
|
94
|
+
costUsd: recs.reduce((n, r) => n + (r.costUsd ?? 0), 0),
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
id: run.id,
|
|
98
|
+
title: run.meta.title,
|
|
99
|
+
repo: run.meta.repo,
|
|
100
|
+
stateTail: lines(join(run.dir, "state.md")).slice(-3),
|
|
101
|
+
live: stillRunning.map((m) => ({
|
|
102
|
+
name: m.name,
|
|
103
|
+
rung: m.rung,
|
|
104
|
+
secs: Math.round((now - Date.parse(m.startedAt)) / 1000),
|
|
105
|
+
pid: m.pid,
|
|
106
|
+
})),
|
|
107
|
+
totals,
|
|
108
|
+
jev: { decisions: jev.length, fallbacks: jev.filter((j) => j.source === "default").length },
|
|
109
|
+
milestones: lines(join(run.dir, "ledger.md")).slice(1),
|
|
110
|
+
harness: harnessCosts(listRuns().map((r) => r.dir)),
|
|
111
|
+
budget: budgetStatus(totals, budgetFor(run)),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const mmss = (s: number) =>
|
|
116
|
+
`${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
117
|
+
|
|
118
|
+
export function formatSummary(s: RunSummary): string {
|
|
119
|
+
const t = s.totals;
|
|
120
|
+
return [
|
|
121
|
+
`== ${s.title} · ${s.id}`,
|
|
122
|
+
...s.stateTail,
|
|
123
|
+
"-- running",
|
|
124
|
+
...(s.live.length ? s.live.map((l) => ` ${l.name} ${l.rung} ${mmss(l.secs)}`) : [" none"]),
|
|
125
|
+
`-- done ${t.runs} runs · ${t.ok} ok · ${Math.round(t.secs / 60)} min · ${t.tokens.input} in / ${t.tokens.output} out tokens · $${t.costUsd.toFixed(2)}${t.notOk.length ? ` · not ok: ${t.notOk.join(", ")}` : ""}`,
|
|
126
|
+
`-- jev ${s.jev.decisions} decisions · ${s.jev.fallbacks} fell back to default`,
|
|
127
|
+
...(s.harness.length
|
|
128
|
+
? s.harness.map((h) => `-- harness ${formatHarness(h)}`)
|
|
129
|
+
: ["-- harness no data yet"]),
|
|
130
|
+
...(s.budget ? [`-- budget ${formatBudget(s.budget)}`] : []),
|
|
131
|
+
...(s.milestones.length ? ["-- landed", ...s.milestones.map((m) => ` ${m}`)] : []),
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RungStats {
|
|
136
|
+
role: Role;
|
|
137
|
+
rung: RungId;
|
|
138
|
+
runs: number;
|
|
139
|
+
ok: number;
|
|
140
|
+
refusals: number;
|
|
141
|
+
climbsFrom: number;
|
|
142
|
+
secs: number;
|
|
143
|
+
tokens: Tokens;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function runsSummary(
|
|
147
|
+
f: { run?: string; repo?: string; role?: Role; sinceDays?: number } = {},
|
|
148
|
+
): RungStats[] {
|
|
149
|
+
const since = f.sinceDays ? Date.now() - f.sinceDays * 86_400_000 : 0;
|
|
150
|
+
const runs = listRuns().filter(
|
|
151
|
+
(r) =>
|
|
152
|
+
(!f.run || r.id === f.run) &&
|
|
153
|
+
(!f.repo || r.meta.repo === f.repo) &&
|
|
154
|
+
Date.parse(r.meta.createdAt) >= since,
|
|
155
|
+
);
|
|
156
|
+
const stats = new Map<string, RungStats>();
|
|
157
|
+
const at = (role: Role, rung: RungId): RungStats => {
|
|
158
|
+
const key = `${role} ${rung}`;
|
|
159
|
+
let s = stats.get(key);
|
|
160
|
+
if (!s) {
|
|
161
|
+
s = {
|
|
162
|
+
role,
|
|
163
|
+
rung,
|
|
164
|
+
runs: 0,
|
|
165
|
+
ok: 0,
|
|
166
|
+
refusals: 0,
|
|
167
|
+
climbsFrom: 0,
|
|
168
|
+
secs: 0,
|
|
169
|
+
tokens: { input: 0, cached: 0, output: 0 },
|
|
170
|
+
};
|
|
171
|
+
stats.set(key, s);
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
};
|
|
175
|
+
for (const run of runs) {
|
|
176
|
+
for (const r of readRunRecords(run.dir)) {
|
|
177
|
+
if (f.role && r.role !== f.role) continue;
|
|
178
|
+
const s = at(r.role, r.rung);
|
|
179
|
+
s.runs++;
|
|
180
|
+
if (r.status === "ok") s.ok++;
|
|
181
|
+
if (r.replyStatus === "refused" || r.replyStatus === "blocked") s.refusals++;
|
|
182
|
+
s.secs += r.secs;
|
|
183
|
+
s.tokens.input += r.tokens.input;
|
|
184
|
+
s.tokens.cached += r.tokens.cached;
|
|
185
|
+
s.tokens.output += r.tokens.output;
|
|
186
|
+
}
|
|
187
|
+
for (const c of readRoutes(run.dir)) {
|
|
188
|
+
if (c.source !== "climb" || !c.from || (f.role && c.role !== f.role)) continue;
|
|
189
|
+
at(c.role, c.from).climbsFrom++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return [...stats.values()].sort((a, b) => a.role.localeCompare(b.role) || a.rung.localeCompare(b.rung));
|
|
193
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
// This module sits one level below the package root as src/files.ts, and catherd ships with no
|
|
6
|
+
// build step, so one relative URL finds shipped files both in tests and after install alike.
|
|
7
|
+
export const assetPath = (rel: string): string => fileURLToPath(new URL(`../${rel}`, import.meta.url));
|
|
8
|
+
|
|
9
|
+
export function readJsonFile<T>(schema: z.ZodType<T>, file: string): T {
|
|
10
|
+
let raw: unknown;
|
|
11
|
+
try {
|
|
12
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
13
|
+
} catch (e) {
|
|
14
|
+
throw new Error(`catherd: ${file} is not readable JSON: ${(e as Error).message}`);
|
|
15
|
+
}
|
|
16
|
+
const r = schema.safeParse(raw);
|
|
17
|
+
if (!r.success) throw new Error(`catherd: ${file} is invalid:\n${z.prettifyError(r.error)}`);
|
|
18
|
+
return r.data;
|
|
19
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type DispatchOpts, runCodex } from "../core/codex.ts";
|
|
5
|
+
import { recordHarness } from "../core/harness.ts";
|
|
6
|
+
import { ID, ownedFilesOf, readLane } from "../core/lanes.ts";
|
|
7
|
+
import { runOpencode } from "../core/opencode.ts";
|
|
8
|
+
import { reconcileLive } from "../core/reconcile.ts";
|
|
9
|
+
import { readLive, rolePaths } from "../core/runstore.ts";
|
|
10
|
+
import { budgetStatus, formatBudget, summarizeRun } from "../core/status.ts";
|
|
11
|
+
import { agentName } from "../profile/agents.ts";
|
|
12
|
+
import { activeProfileName, loadProfile } from "../profile/profile.ts";
|
|
13
|
+
import { loadCatalog } from "../routing/catalog.ts";
|
|
14
|
+
import { ROLES, type RunRecord } from "../types.ts";
|
|
15
|
+
import { json } from "./out.ts";
|
|
16
|
+
import { backendOf, findRun, refreshState } from "./runs.ts";
|
|
17
|
+
|
|
18
|
+
export function dispatchHints(r: RunRecord, owned: string[]): string[] {
|
|
19
|
+
const h: string[] = [];
|
|
20
|
+
if (r.status === "limit")
|
|
21
|
+
h.push(`limit: ${r.backend} hit a usage limit; the run is paused, report it and push`);
|
|
22
|
+
if (r.status === "cli-too-old")
|
|
23
|
+
h.push(`cli-too-old: tell the user to upgrade ${r.backend}: ${r.error ?? ""}`.trim());
|
|
24
|
+
if (r.status === "failed") h.push(`failed: read roles/${r.name}.err before a retry`);
|
|
25
|
+
if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
|
|
26
|
+
else if (r.status === "ok" && owned.length > 0 && r.changedOwned.length === 0) h.push("climb: unchanged");
|
|
27
|
+
if (r.threadHeavy) h.push("thread-heavy: its next piece starts on a fresh thread");
|
|
28
|
+
return h;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerDispatch(server: McpServer): void {
|
|
32
|
+
server.registerTool(
|
|
33
|
+
"dispatch",
|
|
34
|
+
{
|
|
35
|
+
description:
|
|
36
|
+
"Run one Codex or opencode role and return its RESULT record and hints when it finishes. The brief is the text itself; catherd writes it to roles/<name>.md, or roles/<name>.fix.md when resuming a thread. With lane, the lane file's Owns: line gives the owned files, and a lane overlapping a running one is refused. Call it from the main thread.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
run: z.string(),
|
|
39
|
+
role: z.enum(ROLES),
|
|
40
|
+
name: z.string().regex(ID),
|
|
41
|
+
brief: z.string().min(1),
|
|
42
|
+
rung: z.string().min(3),
|
|
43
|
+
thread: z.string().optional(),
|
|
44
|
+
lane: z.string().regex(ID).optional(),
|
|
45
|
+
next: z.string().optional(),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
async (a, extra) => {
|
|
49
|
+
const run = findRun(a.run);
|
|
50
|
+
reconcileLive(run.dir);
|
|
51
|
+
const catalog = loadCatalog();
|
|
52
|
+
const backend = backendOf(catalog, a.rung);
|
|
53
|
+
if (backend === "claude") {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`catherd: ${a.rung} is a Claude rung. Run it as Agent(subagent_type: "${agentName(a.role, a.rung)}") instead.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const profile = loadProfile(activeProfileName(run.meta.repo));
|
|
59
|
+
// spec §11b: at 100% of the run budget, refuse rather than start another role that might overrun it.
|
|
60
|
+
const budget = budgetStatus(summarizeRun(run).totals, profile.budget);
|
|
61
|
+
if (budget && budget.fraction >= 1) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`catherd: run budget exhausted (${formatBudget(budget)}); ask the user before continuing`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const owned = a.lane ? ownedFilesOf(readLane(run.dir, a.lane)) : [];
|
|
67
|
+
if (a.lane && owned.length === 0) throw new Error(`catherd: lanes/${a.lane}.md has no "Owns:" line`);
|
|
68
|
+
const live = readLive(run.dir);
|
|
69
|
+
if (live.some((m) => m.name === a.name)) throw new Error(`catherd: ${a.name} is already running`);
|
|
70
|
+
for (const m of live) {
|
|
71
|
+
const shared = m.ownedFiles.filter((f) => owned.includes(f));
|
|
72
|
+
if (shared.length) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`catherd: ${a.name} owns ${shared.join(", ")}, which running ${m.name} owns; dispatch it after that one`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const p = rolePaths(run.dir, a.name);
|
|
80
|
+
writeFileSync(a.thread ? p.fix : p.brief, a.brief);
|
|
81
|
+
await refreshState(run, {
|
|
82
|
+
starting: {
|
|
83
|
+
name: a.name,
|
|
84
|
+
rung: a.rung,
|
|
85
|
+
thread: a.thread ?? null,
|
|
86
|
+
brief: `roles/${a.name}${a.thread ? ".fix" : ""}.md`,
|
|
87
|
+
since: new Date().toISOString().slice(11, 16),
|
|
88
|
+
},
|
|
89
|
+
...(a.next ? { next: a.next } : {}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const token = extra._meta?.progressToken;
|
|
93
|
+
let tick = 0;
|
|
94
|
+
const runOne = (
|
|
95
|
+
rung: string,
|
|
96
|
+
thread: string | undefined,
|
|
97
|
+
rb: "codex" | "opencode",
|
|
98
|
+
): Promise<RunRecord> => {
|
|
99
|
+
const o: DispatchOpts = {
|
|
100
|
+
runDir: run.dir,
|
|
101
|
+
name: a.name,
|
|
102
|
+
role: a.role,
|
|
103
|
+
rung,
|
|
104
|
+
cwd: run.meta.repo,
|
|
105
|
+
thread,
|
|
106
|
+
ownedFiles: owned,
|
|
107
|
+
isolated: profile.harness[rb].isolated,
|
|
108
|
+
onProgress:
|
|
109
|
+
token === undefined
|
|
110
|
+
? undefined
|
|
111
|
+
: (pr) => {
|
|
112
|
+
void extra.sendNotification({
|
|
113
|
+
method: "notifications/progress",
|
|
114
|
+
params: {
|
|
115
|
+
progressToken: token,
|
|
116
|
+
progress: ++tick,
|
|
117
|
+
message: `${a.name} · ${rung} · ${pr.secs}s${pr.lastEvent ? ` · ${pr.lastEvent}` : ""}`,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return rb === "codex" ? runCodex(o) : runOpencode(o);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
let record = await runOne(a.rung, a.thread, backend);
|
|
126
|
+
if (!a.thread) recordHarness(run.dir, record);
|
|
127
|
+
|
|
128
|
+
// spec §11b: a limit result re-dispatches on the profile's failover stand-in for this rung.
|
|
129
|
+
const hints: string[] = [];
|
|
130
|
+
const failoverTo = profile.failover?.[a.rung];
|
|
131
|
+
if (record.status === "limit" && failoverTo) {
|
|
132
|
+
const toBackend = backendOf(catalog, failoverTo);
|
|
133
|
+
if (toBackend === "claude") {
|
|
134
|
+
hints.push(
|
|
135
|
+
`limit: ${a.rung} hit a usage limit; its failover ${failoverTo} is a Claude rung — run it as Agent(subagent_type: "${agentName(a.role, failoverTo)}")`,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
const failedRung = record.rung;
|
|
139
|
+
record = await runOne(failoverTo, undefined, toBackend);
|
|
140
|
+
if (!a.thread) recordHarness(run.dir, record);
|
|
141
|
+
hints.push(`limit: ${a.role} ${failedRung} hit a usage limit; failed over to ${failoverTo}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await refreshState(
|
|
146
|
+
run,
|
|
147
|
+
record.status === "limit"
|
|
148
|
+
? { next: `paused: ${record.backend} usage limit; resume when the user says so` }
|
|
149
|
+
: {},
|
|
150
|
+
);
|
|
151
|
+
return json({ record, hints: [...hints, ...dispatchHints(record, owned)] });
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|