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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { relative, sep } from "node:path";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import { assertId, ID_PATTERN, parseRung } from "../domain/ids.ts";
|
|
5
|
+
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
6
|
+
import type { Role } from "../domain/roles.ts";
|
|
7
|
+
import {
|
|
8
|
+
type ClimbReason,
|
|
9
|
+
currentRoute,
|
|
10
|
+
laneOutcome,
|
|
11
|
+
nextRung,
|
|
12
|
+
type RouteJev,
|
|
13
|
+
type RouteSource,
|
|
14
|
+
} from "../domain/route.ts";
|
|
15
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
16
|
+
import { commitExists } from "../infra/git.ts";
|
|
17
|
+
import { budgetOf } from "./budget.ts";
|
|
18
|
+
import type { Deps, Verdict } from "./ports.ts";
|
|
19
|
+
import {
|
|
20
|
+
appendLedger,
|
|
21
|
+
appendOutcome,
|
|
22
|
+
appendRoute,
|
|
23
|
+
findRun,
|
|
24
|
+
knowledgeFile,
|
|
25
|
+
readRoutes,
|
|
26
|
+
type Run,
|
|
27
|
+
runFile,
|
|
28
|
+
runPaths,
|
|
29
|
+
} from "./run-store.ts";
|
|
30
|
+
import { type Notes, type NotesPatch, refreshState } from "./state.ts";
|
|
31
|
+
import { appendPrivate } from "../infra/store.ts";
|
|
32
|
+
|
|
33
|
+
const withHints = (hints: string[]) => (hints.length ? { hints } : {});
|
|
34
|
+
|
|
35
|
+
export interface RouteResult {
|
|
36
|
+
lane: string | null;
|
|
37
|
+
role: Role;
|
|
38
|
+
rung: string;
|
|
39
|
+
ladder: string[];
|
|
40
|
+
source: RouteSource;
|
|
41
|
+
kind: Kind | null;
|
|
42
|
+
difficulty: Difficulty | null;
|
|
43
|
+
backend: string;
|
|
44
|
+
/** the native agent to run a `claude:` rung as */
|
|
45
|
+
agent: string | null;
|
|
46
|
+
/** the Jev question set asked, and what it said, when Jev was asked */
|
|
47
|
+
questionSet: string | null;
|
|
48
|
+
jev: RouteJev | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readLaneFile(run: Run, path: string): { lane: string; text: string } {
|
|
52
|
+
const file = runFile(run, path, "read");
|
|
53
|
+
const [folder, name, ...deeper] = relative(run.dir, file).split(sep);
|
|
54
|
+
const lane = folder === "lanes" && deeper.length === 0 && name?.endsWith(".md") ? name.slice(0, -3) : null;
|
|
55
|
+
if (!lane || !ID_PATTERN.test(lane))
|
|
56
|
+
throw new CatherdError("E_LANE_INVALID", `a lane file lives at lanes/<id>.md, not ${path}`, {
|
|
57
|
+
fix: "pass lane_file as lanes/Mx.Ly.md",
|
|
58
|
+
});
|
|
59
|
+
if (!existsSync(file))
|
|
60
|
+
throw new CatherdError("E_LANE_INVALID", `no lane file ${path}`, {
|
|
61
|
+
fix: "write it with write_run_file first",
|
|
62
|
+
});
|
|
63
|
+
return { lane, text: readFileSync(file, "utf8") };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Spec §5.4 through the routing port; a lane's route is recorded in routes.jsonl. */
|
|
67
|
+
export async function route(
|
|
68
|
+
deps: Deps,
|
|
69
|
+
i: { run: string; laneFile?: string; role: Role },
|
|
70
|
+
): Promise<RouteResult> {
|
|
71
|
+
const run = findRun(i.run);
|
|
72
|
+
const lane = i.laneFile === undefined ? null : readLaneFile(run, i.laneFile);
|
|
73
|
+
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
74
|
+
const a = await deps.routing.route({
|
|
75
|
+
runDir: run.dir,
|
|
76
|
+
repo: run.meta.repo,
|
|
77
|
+
profile,
|
|
78
|
+
role: i.role,
|
|
79
|
+
lane: lane?.lane ?? null,
|
|
80
|
+
laneText: lane?.text ?? null,
|
|
81
|
+
spentFraction: budgetOf(run, profile.budget, deps.now())?.fraction ?? 0,
|
|
82
|
+
});
|
|
83
|
+
if (lane)
|
|
84
|
+
appendRoute(run, {
|
|
85
|
+
at: new Date(deps.now()).toISOString(),
|
|
86
|
+
lane: lane.lane,
|
|
87
|
+
role: i.role,
|
|
88
|
+
rung: a.rung,
|
|
89
|
+
ladder: a.ladder,
|
|
90
|
+
source: "route",
|
|
91
|
+
decidedBy: a.source,
|
|
92
|
+
from: null,
|
|
93
|
+
reason: null,
|
|
94
|
+
kind: a.kind,
|
|
95
|
+
difficulty: a.difficulty,
|
|
96
|
+
questionSet: a.questionSet,
|
|
97
|
+
jev: a.jev,
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
lane: lane?.lane ?? null,
|
|
101
|
+
role: i.role,
|
|
102
|
+
...a,
|
|
103
|
+
backend: parseRung(a.rung).backend,
|
|
104
|
+
agent: deps.profiles.agentFor(run.meta.repo, i.role, a.rung),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Spec §4.5: one rung up the lane's ladder, on a fresh thread; the reason goes to routes.jsonl. */
|
|
109
|
+
export async function climb(
|
|
110
|
+
deps: Deps,
|
|
111
|
+
i: { run: string; lane: string; reason: ClimbReason; evidence?: string; env?: boolean },
|
|
112
|
+
): Promise<{
|
|
113
|
+
lane: string;
|
|
114
|
+
rung: string;
|
|
115
|
+
top: boolean;
|
|
116
|
+
backend: string;
|
|
117
|
+
agent: string | null;
|
|
118
|
+
hints?: string[];
|
|
119
|
+
}> {
|
|
120
|
+
const run = findRun(i.run);
|
|
121
|
+
assertId("lane", i.lane);
|
|
122
|
+
const { cur, next } = await withFileLock(runPaths(run.dir).routes, () => {
|
|
123
|
+
const cur = currentRoute(readRoutes(run), i.lane);
|
|
124
|
+
if (!cur)
|
|
125
|
+
throw new CatherdError("E_LANE_INVALID", `lane ${i.lane} was never routed`, {
|
|
126
|
+
fix: `route(run, "lanes/${i.lane}.md") first`,
|
|
127
|
+
});
|
|
128
|
+
const next = nextRung(cur.ladder, cur.rung);
|
|
129
|
+
appendRoute(run, {
|
|
130
|
+
...cur,
|
|
131
|
+
at: new Date(deps.now()).toISOString(),
|
|
132
|
+
source: "climb",
|
|
133
|
+
from: cur.rung,
|
|
134
|
+
rung: next ?? cur.rung,
|
|
135
|
+
reason: i.evidence ? `${i.reason}: ${i.evidence}` : i.reason,
|
|
136
|
+
env: i.env === true,
|
|
137
|
+
});
|
|
138
|
+
// spec §5.6: a climb past the top rung ends the lane open
|
|
139
|
+
if (!next) {
|
|
140
|
+
const o = laneOutcome(readRoutes(run), i.lane, false, new Date(deps.now()).toISOString());
|
|
141
|
+
if (o) appendOutcome(run, o);
|
|
142
|
+
}
|
|
143
|
+
return { cur, next };
|
|
144
|
+
});
|
|
145
|
+
const { hints } = await refreshState(run, {
|
|
146
|
+
next: next
|
|
147
|
+
? `dispatch ${i.lane} at ${next} on a fresh thread`
|
|
148
|
+
: `${i.lane} failed on its top rung: ask finding, then the architect or the report`,
|
|
149
|
+
});
|
|
150
|
+
const rung = next ?? cur.rung;
|
|
151
|
+
return {
|
|
152
|
+
lane: i.lane,
|
|
153
|
+
rung,
|
|
154
|
+
top: next === null,
|
|
155
|
+
backend: parseRung(rung).backend,
|
|
156
|
+
agent: next ? deps.profiles.agentFor(run.meta.repo, cur.role, next) : null,
|
|
157
|
+
...withHints(hints),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const cell = (s: string) => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Spec §4.7: the full five-column ledger row, with the minutes since the previous landing (or the
|
|
165
|
+
* run's start), and `learned` appended to the repo's knowledge.md.
|
|
166
|
+
*/
|
|
167
|
+
export async function land(
|
|
168
|
+
deps: Deps,
|
|
169
|
+
i: {
|
|
170
|
+
run: string;
|
|
171
|
+
milestone: string;
|
|
172
|
+
what: string;
|
|
173
|
+
commit: string;
|
|
174
|
+
evidence: string;
|
|
175
|
+
next: string;
|
|
176
|
+
learned?: string;
|
|
177
|
+
},
|
|
178
|
+
): Promise<{ ledger: string; minutes: number; hints?: string[] }> {
|
|
179
|
+
const run = findRun(i.run);
|
|
180
|
+
// commitExists throws E_IO_UNEXPECTED on a timeout, which reaches the caller as is
|
|
181
|
+
if (!/^[0-9a-f]{7,40}$/.test(i.commit) || !(await commitExists(run.meta.repo, i.commit)))
|
|
182
|
+
throw new CatherdError("E_RUN_COMMIT", `no commit ${i.commit} in ${run.meta.repo}`, {
|
|
183
|
+
fix: "commit the milestone first, then pass its hash",
|
|
184
|
+
});
|
|
185
|
+
const now = new Date(deps.now());
|
|
186
|
+
let row = "";
|
|
187
|
+
let minutes = 0;
|
|
188
|
+
const landRow = (notes: Notes): NotesPatch => {
|
|
189
|
+
minutes = Math.max(
|
|
190
|
+
0,
|
|
191
|
+
Math.round((now.getTime() - Date.parse(notes.lastLandedAt ?? run.meta.createdAt)) / 60_000),
|
|
192
|
+
);
|
|
193
|
+
row = [i.milestone, i.what, i.commit, String(minutes), i.evidence].map(cell).join(" | ");
|
|
194
|
+
appendLedger(run, row);
|
|
195
|
+
return { lastCheck: cell(i.evidence), next: i.next, lastLandedAt: now.toISOString() };
|
|
196
|
+
};
|
|
197
|
+
// on a failed refresh the notes still reach state.json, so the next landing counts its minutes from this one
|
|
198
|
+
const { hints } = await refreshState(run, landRow);
|
|
199
|
+
// spec §5.6: every routed lane of the milestone lands with it
|
|
200
|
+
// under the routes lock, so a racing climb cannot slip between the read and the rows
|
|
201
|
+
let routed: string[] = [];
|
|
202
|
+
let landed = 0;
|
|
203
|
+
await withFileLock(runPaths(run.dir).routes, () => {
|
|
204
|
+
const routes = readRoutes(run);
|
|
205
|
+
routed = [...new Set(routes.map((r) => r.lane))];
|
|
206
|
+
for (const lane of routed)
|
|
207
|
+
if (lane.startsWith(`${i.milestone}.`)) {
|
|
208
|
+
landed++;
|
|
209
|
+
const o = laneOutcome(routes, lane, true, now.toISOString());
|
|
210
|
+
if (o) appendOutcome(run, o);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
// a milestone name no routed lane starts with is most likely a typo: say so rather than record nothing
|
|
214
|
+
if (routed.length > 0 && landed === 0)
|
|
215
|
+
hints.push(
|
|
216
|
+
`land: no routed lane is in milestone "${i.milestone}" (routed: ${routed.slice(0, 5).join(", ")}${routed.length > 5 ? ", …" : ""}); check its name: no lane outcome was recorded`,
|
|
217
|
+
);
|
|
218
|
+
if (i.learned) {
|
|
219
|
+
appendPrivate(
|
|
220
|
+
knowledgeFile(run.meta.repo),
|
|
221
|
+
`- ${now.toISOString().slice(0, 10)} ${run.meta.title} ${i.milestone}: ${cell(i.learned)}\n`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return { ledger: row, minutes, ...withHints(hints) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Jev's `finding` or `same-defect` answer, through the routing port. */
|
|
228
|
+
export async function ask(
|
|
229
|
+
deps: Deps,
|
|
230
|
+
i: { run: string; question: "finding" | "same-defect"; state: Record<string, string> },
|
|
231
|
+
): Promise<Verdict<string>> {
|
|
232
|
+
const run = findRun(i.run);
|
|
233
|
+
const need = (k: string): string => {
|
|
234
|
+
const v = i.state[k];
|
|
235
|
+
if (!v)
|
|
236
|
+
throw new CatherdError("E_INPUT_INVALID", `ask ${i.question} needs state.${k}`, {
|
|
237
|
+
fix: i.question === "finding" ? "pass state { lane_file, finding }" : "pass state { before, after }",
|
|
238
|
+
});
|
|
239
|
+
return v;
|
|
240
|
+
};
|
|
241
|
+
// spec §5.5: a profile with Jev off never asks it, for routing or for these verdicts
|
|
242
|
+
const use = deps.profiles.forRepo(run.meta.repo).jev.use;
|
|
243
|
+
if (i.question === "finding") {
|
|
244
|
+
const { text } = readLaneFile(run, need("lane_file"));
|
|
245
|
+
return deps.routing.finding(run.dir, text, need("finding"), use);
|
|
246
|
+
}
|
|
247
|
+
return deps.routing.sameDefect(run.dir, need("before"), need("after"), use);
|
|
248
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Budget } from "../domain/budget.ts";
|
|
2
|
+
import type { BillingMode } from "../domain/cost.ts";
|
|
3
|
+
import type { Verdict } from "../domain/jev.ts";
|
|
4
|
+
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
5
|
+
import type { Access } from "../domain/record.ts";
|
|
6
|
+
import type { Role } from "../domain/roles.ts";
|
|
7
|
+
import type { Change, ProfilePatch } from "../domain/profile.ts";
|
|
8
|
+
import type { Issue } from "../domain/profile-rules.ts";
|
|
9
|
+
import type { RouteJev, RouteSource } from "../domain/route.ts";
|
|
10
|
+
|
|
11
|
+
/** What the run lifecycle reads from a profile. Rungs are `backend:model#effort`; a role's in ladder order. */
|
|
12
|
+
export interface ProfileView {
|
|
13
|
+
name: string;
|
|
14
|
+
objective: "cost" | "speed";
|
|
15
|
+
/** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
|
|
16
|
+
roles: Partial<Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string }>>;
|
|
17
|
+
/** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
|
|
18
|
+
billing: Partial<Record<string, BillingMode>>;
|
|
19
|
+
jev: { use: "auto" | "off" };
|
|
20
|
+
/** per backend id: run its harness isolated (spec §7.1 `harness`) */
|
|
21
|
+
isolated: Partial<Record<string, boolean>>;
|
|
22
|
+
/** rung → its stand-in on a usage limit (spec §4.5) */
|
|
23
|
+
failover: Record<string, string>;
|
|
24
|
+
budget: Budget;
|
|
25
|
+
timeouts: { idleMin: number; wallMin: number };
|
|
26
|
+
preflight: { confirm: boolean };
|
|
27
|
+
heavy: number | "cpus/2";
|
|
28
|
+
notify: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a profile write returns (spec §7.3): whether it saved, why not, what changed, and the agents it touched. */
|
|
32
|
+
export interface ProfileSaved {
|
|
33
|
+
saved: boolean;
|
|
34
|
+
errors: Issue[];
|
|
35
|
+
warnings: Issue[];
|
|
36
|
+
diff: Change[];
|
|
37
|
+
linked: string[];
|
|
38
|
+
pruned: string[];
|
|
39
|
+
/** agents whose link is new or whose file changed: they apply from the next Claude Code session */
|
|
40
|
+
newSessionNeededFor: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ProfilePort {
|
|
44
|
+
/** The profile bound to `repo` (a git toplevel), else the active one; null asks for the active one. */
|
|
45
|
+
forRepo(repo: string | null): ProfileView;
|
|
46
|
+
/** Without a name (get, validate, set): the profile `repo`, a repository's toplevel, runs on. */
|
|
47
|
+
get(
|
|
48
|
+
name?: string,
|
|
49
|
+
repo?: string | null,
|
|
50
|
+
): {
|
|
51
|
+
/** the global active profile */
|
|
52
|
+
active: string;
|
|
53
|
+
/** the profile `repo` runs on: its binding, else the active one */
|
|
54
|
+
here: string;
|
|
55
|
+
profiles: string[];
|
|
56
|
+
profile: ProfileView;
|
|
57
|
+
/** per role: whether its backend holds it to its access mode (spec D10) */
|
|
58
|
+
enforcement: Partial<Record<Role, "enforced" | "advisory">>;
|
|
59
|
+
};
|
|
60
|
+
validate(name?: string, repo?: string | null): { valid: boolean; errors: Issue[]; warnings: Issue[] };
|
|
61
|
+
set(name: string | undefined, patch: ProfilePatch, repo?: string | null): ProfileSaved;
|
|
62
|
+
/** The native agent that runs `rung` for `role` under `repo`'s profile; null unless it is a `claude:` rung. */
|
|
63
|
+
agentFor(repo: string | null, role: Role, rung: string): string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RouteRequest {
|
|
67
|
+
runDir: string;
|
|
68
|
+
repo: string;
|
|
69
|
+
profile: ProfileView;
|
|
70
|
+
role: Role;
|
|
71
|
+
/** the lane's id, for jev.jsonl; null when routing a role without a lane */
|
|
72
|
+
lane: string | null;
|
|
73
|
+
laneText: string | null;
|
|
74
|
+
spentFraction: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RouteAnswer {
|
|
78
|
+
rung: string;
|
|
79
|
+
ladder: string[];
|
|
80
|
+
source: RouteSource;
|
|
81
|
+
kind: Kind | null;
|
|
82
|
+
difficulty: Difficulty | null;
|
|
83
|
+
/** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
|
|
84
|
+
questionSet: string | null;
|
|
85
|
+
jev: RouteJev | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type { Verdict };
|
|
89
|
+
|
|
90
|
+
export interface CatalogFilter {
|
|
91
|
+
role?: Role;
|
|
92
|
+
backend?: string;
|
|
93
|
+
text?: string;
|
|
94
|
+
scoredOnly: boolean;
|
|
95
|
+
limit: number;
|
|
96
|
+
/** the git toplevel whose per-repository listings (opencode's) to read; none reads the global ones */
|
|
97
|
+
repo?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RoutingPort {
|
|
101
|
+
route(req: RouteRequest): Promise<RouteAnswer>;
|
|
102
|
+
/** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
|
|
103
|
+
finding(
|
|
104
|
+
runDir: string,
|
|
105
|
+
laneText: string,
|
|
106
|
+
finding: string,
|
|
107
|
+
use: "auto" | "off",
|
|
108
|
+
): Promise<Verdict<"design" | "code" | "unclear">>;
|
|
109
|
+
sameDefect(
|
|
110
|
+
runDir: string,
|
|
111
|
+
before: string,
|
|
112
|
+
after: string,
|
|
113
|
+
use: "auto" | "off",
|
|
114
|
+
): Promise<Verdict<"yes" | "no">>;
|
|
115
|
+
/** `billing` prices the rungs (spec §5.3); absent keys bill as DEFAULT_BILLING */
|
|
116
|
+
catalog(
|
|
117
|
+
filter: CatalogFilter,
|
|
118
|
+
billing?: Partial<Record<string, BillingMode>>,
|
|
119
|
+
): { total: number; models: unknown[] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Everything a service needs from outside it; the entry layer builds one, tests build fakes. */
|
|
123
|
+
export interface Deps {
|
|
124
|
+
profiles: ProfilePort;
|
|
125
|
+
routing: RoutingPort;
|
|
126
|
+
version: string;
|
|
127
|
+
/** how often the supervisor and the dispatch wait poll, in ms */
|
|
128
|
+
pollMs: number;
|
|
129
|
+
/** how often a running dispatch reports progress, in ms (spec §4.4: 30 s) */
|
|
130
|
+
tickMs: number;
|
|
131
|
+
now: () => number;
|
|
132
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseLaneHeader } from "../domain/lane.ts";
|
|
4
|
+
import { checkEnv } from "../infra/env.ts";
|
|
5
|
+
import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
|
|
6
|
+
import { killGroup } from "../infra/proc.ts";
|
|
7
|
+
import type { Deps } from "./ports.ts";
|
|
8
|
+
import { TAIL_LINES } from "./run-debug.ts";
|
|
9
|
+
import { findRun, type Run, runPaths } from "./run-store.ts";
|
|
10
|
+
|
|
11
|
+
/** Spec §4.7. Only `cannot-start` blocks the run. */
|
|
12
|
+
export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
|
|
13
|
+
|
|
14
|
+
interface PreflightResult {
|
|
15
|
+
lane: string;
|
|
16
|
+
check: string | null;
|
|
17
|
+
outcome: PreflightOutcome;
|
|
18
|
+
exitCode: number | null;
|
|
19
|
+
tail: string[];
|
|
20
|
+
note: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PreflightReport =
|
|
24
|
+
| { needsConfirmation: true; commands: { lane: string; check: string | null }[] }
|
|
25
|
+
| {
|
|
26
|
+
needsConfirmation: false;
|
|
27
|
+
commands: { lane: string; check: string | null }[];
|
|
28
|
+
results: PreflightResult[];
|
|
29
|
+
blocked: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const CHECK_TIMEOUT_MS = 120_000;
|
|
33
|
+
|
|
34
|
+
/** Whose uid preflight runs under; tests stand in for root with it. */
|
|
35
|
+
export const preflightUser = { uid: (): number => process.getuid?.() ?? -1 };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Spec §10.4: a lane's check never runs as root. A machine that says it is disposable (IS_SANDBOX, the
|
|
39
|
+
* rule claude itself applies to root) is the one exception.
|
|
40
|
+
*/
|
|
41
|
+
const refusesRoot = (): boolean => preflightUser.uid() === 0 && !process.env.IS_SANDBOX;
|
|
42
|
+
const AS_ROOT =
|
|
43
|
+
"not run: catherd runs as root, and preflight never runs a lane's check as root (spec §10.4); run catherd as a normal user, or set IS_SANDBOX=1 on a disposable machine";
|
|
44
|
+
|
|
45
|
+
interface LaneCheck {
|
|
46
|
+
lane: string;
|
|
47
|
+
check: string | null;
|
|
48
|
+
owns: string[];
|
|
49
|
+
problem: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function laneChecks(run: Run): LaneCheck[] {
|
|
53
|
+
const dir = runPaths(run.dir).lanes;
|
|
54
|
+
if (!existsSync(dir)) return [];
|
|
55
|
+
return readdirSync(dir)
|
|
56
|
+
.filter((f) => f.endsWith(".md"))
|
|
57
|
+
.sort()
|
|
58
|
+
.map((f) => {
|
|
59
|
+
const lane = f.slice(0, -".md".length);
|
|
60
|
+
try {
|
|
61
|
+
const h = parseLaneHeader(readFileSync(join(dir, f), "utf8"));
|
|
62
|
+
return {
|
|
63
|
+
lane,
|
|
64
|
+
check: h.fastCheck,
|
|
65
|
+
owns: h.owns,
|
|
66
|
+
problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
|
|
67
|
+
};
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { lane, check: null, owns: [], problem: (e as Error).message };
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How long the pipes may stay open after the check's own process exits. */
|
|
75
|
+
export const DRAIN_MS = 500;
|
|
76
|
+
|
|
77
|
+
/** Reads a pipe chunk by chunk, so what arrived is kept when the reading stops early. */
|
|
78
|
+
export function collect(stream: ReadableStream<Uint8Array>): {
|
|
79
|
+
done: Promise<void>;
|
|
80
|
+
text: () => string;
|
|
81
|
+
stop: () => void;
|
|
82
|
+
} {
|
|
83
|
+
const reader = stream.getReader();
|
|
84
|
+
const decoder = new TextDecoder();
|
|
85
|
+
let text = "";
|
|
86
|
+
const done = (async () => {
|
|
87
|
+
for (;;) {
|
|
88
|
+
const { value, done } = await reader.read();
|
|
89
|
+
if (done) return;
|
|
90
|
+
text += decoder.decode(value, { stream: true });
|
|
91
|
+
}
|
|
92
|
+
})().catch(() => {
|
|
93
|
+
// a pipe that breaks ends the reading; what arrived is kept
|
|
94
|
+
});
|
|
95
|
+
// cancelling a pipe that is already closed has nothing to report
|
|
96
|
+
return { done, text: () => text, stop: () => void reader.cancel().catch(() => {}) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Runs `check` with `sh -c` in its own process group, with an allowlisted env (no credentials), killing the
|
|
101
|
+
* group on timeout. A process the check leaves behind (even in a new session) may hold the pipes open: once
|
|
102
|
+
* the check exits, the pipes get DRAIN_MS to close, then the reading stops with what arrived.
|
|
103
|
+
*/
|
|
104
|
+
export async function runCheck(
|
|
105
|
+
repo: string,
|
|
106
|
+
check: string,
|
|
107
|
+
timeoutMs: number,
|
|
108
|
+
): Promise<{ code: number | null; timedOut: boolean; tail: string[] }> {
|
|
109
|
+
const p = Bun.spawn(["sh", "-c", check], {
|
|
110
|
+
cwd: repo,
|
|
111
|
+
env: checkEnv(process.env, repo),
|
|
112
|
+
stdin: "ignore",
|
|
113
|
+
stdout: "pipe",
|
|
114
|
+
stderr: "pipe",
|
|
115
|
+
detached: true,
|
|
116
|
+
});
|
|
117
|
+
let timedOut = false;
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
timedOut = true;
|
|
120
|
+
killGroup(p.pid, "SIGKILL");
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
const out = collect(p.stdout);
|
|
123
|
+
const err = collect(p.stderr);
|
|
124
|
+
try {
|
|
125
|
+
// the timeout kills the group, so the check's own process always exits
|
|
126
|
+
const code = await p.exited;
|
|
127
|
+
clearTimeout(timer); // the check is done: a slow drain is not a timeout
|
|
128
|
+
const drained = await Promise.race([
|
|
129
|
+
Promise.all([out.done, err.done]).then(() => true),
|
|
130
|
+
Bun.sleep(DRAIN_MS).then(() => false),
|
|
131
|
+
]);
|
|
132
|
+
if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the check's group
|
|
133
|
+
const tail = `${out.text()}${err.text()}`
|
|
134
|
+
.split("\n")
|
|
135
|
+
.filter((l) => l.trim())
|
|
136
|
+
.slice(-TAIL_LINES);
|
|
137
|
+
return { code: timedOut ? null : code, timedOut, tail };
|
|
138
|
+
} finally {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
out.stop();
|
|
141
|
+
err.stop();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function classify(r: { code: number | null; timedOut: boolean; tail: string[] }): PreflightOutcome {
|
|
146
|
+
if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
|
|
147
|
+
if (r.code === 0) return "pass";
|
|
148
|
+
return r.tail.some((l) => /command not found/i.test(l)) ? "cannot-start" : "fails-as-expected";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Spec §4.7: each lane's fast check once, on the base tree, behind the heavy lock. A check on a file the
|
|
153
|
+
* lane itself creates is skipped. With `profile.preflight.confirm`, the first call only lists the commands.
|
|
154
|
+
*/
|
|
155
|
+
export async function preflight(
|
|
156
|
+
deps: Deps,
|
|
157
|
+
i: { run: string; confirmed?: boolean; timeoutMs?: number },
|
|
158
|
+
): Promise<PreflightReport> {
|
|
159
|
+
const run = findRun(i.run);
|
|
160
|
+
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
161
|
+
const lanes = laneChecks(run);
|
|
162
|
+
const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
|
|
163
|
+
if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
|
|
164
|
+
const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
|
|
165
|
+
const results: PreflightResult[] = [];
|
|
166
|
+
for (const l of lanes) {
|
|
167
|
+
const check = l.check;
|
|
168
|
+
if (!check) {
|
|
169
|
+
results.push({
|
|
170
|
+
lane: l.lane,
|
|
171
|
+
check,
|
|
172
|
+
outcome: "cannot-start",
|
|
173
|
+
exitCode: null,
|
|
174
|
+
tail: [],
|
|
175
|
+
note: l.problem,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (refusesRoot()) {
|
|
180
|
+
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note: AS_ROOT });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const unborn = l.owns.filter(
|
|
184
|
+
(p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(run.meta.repo, p)),
|
|
185
|
+
);
|
|
186
|
+
if (unborn.length) {
|
|
187
|
+
const note = `${unborn.join(", ")} does not exist yet; the lane creates it`;
|
|
188
|
+
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const r = await withHeavySlot(heavySlots(profile.heavy), () => runCheck(run.meta.repo, check, timeoutMs));
|
|
192
|
+
results.push({
|
|
193
|
+
lane: l.lane,
|
|
194
|
+
check,
|
|
195
|
+
outcome: classify(r),
|
|
196
|
+
exitCode: r.code,
|
|
197
|
+
tail: r.tail,
|
|
198
|
+
note: r.timedOut ? `timed out after ${timeoutMs / 1000} s` : null,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
needsConfirmation: false,
|
|
203
|
+
commands,
|
|
204
|
+
results,
|
|
205
|
+
blocked: results.some((r) => r.outcome === "cannot-start"),
|
|
206
|
+
};
|
|
207
|
+
}
|