catherd-cli 0.2.1 → 1.0.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 +114 -30
- 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 +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -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 +227 -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 +316 -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 +58 -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 +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -26
- 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 +33 -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 +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -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 +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -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 +133 -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 +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -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 +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -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 +196 -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/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -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/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -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 +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -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
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
package/src/core/reconcile.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { RunRecord } from "../types.ts";
|
|
2
|
-
import { finalizeCodex } from "./codex.ts";
|
|
3
|
-
import { finalizeOpencode } from "./opencode.ts";
|
|
4
|
-
import { type LiveMarker, readLive } from "./runstore.ts";
|
|
5
|
-
|
|
6
|
-
export function pidAlive(pid: number): boolean {
|
|
7
|
-
try {
|
|
8
|
-
process.kill(pid, 0);
|
|
9
|
-
return true;
|
|
10
|
-
} catch (e) {
|
|
11
|
-
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function reconcileLive(runDir: string): { finished: RunRecord[]; stillRunning: LiveMarker[] } {
|
|
16
|
-
const finished: RunRecord[] = [];
|
|
17
|
-
const stillRunning: LiveMarker[] = [];
|
|
18
|
-
for (const m of readLive(runDir)) {
|
|
19
|
-
if (pidAlive(m.pid)) {
|
|
20
|
-
stillRunning.push(m);
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
finished.push(m.backend === "codex" ? finalizeCodex(runDir, m, null) : finalizeOpencode(runDir, m, null));
|
|
24
|
-
}
|
|
25
|
-
return { finished, stillRunning };
|
|
26
|
-
}
|
package/src/core/reply.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/runstore.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/spawn.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
}
|
package/src/core/status.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
}
|