catherd-cli 0.2.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,99 @@
1
+ import { homedir } from "node:os";
2
+ import { delimiter, join } from "node:path";
3
+ import { errorMessage } from "../domain/errors.ts";
4
+ import { scrubSecrets } from "../infra/env.ts";
5
+
6
+ /** How `init` finds and installs the global `catherd` (injectable: tests never reach the registry). */
7
+ export interface GlobalInstallDeps {
8
+ /**
9
+ * what the `catherd` in bun's global bin prints for --version; null when there is none or it does not
10
+ * answer. Never PATH: under `bunx catherd-cli init`, PATH first finds bunx's own transient shim.
11
+ */
12
+ globalVersion(): Promise<string | null>;
13
+ /** what the first `catherd` on PATH outside a bunx transient install prints for --version, or null */
14
+ pathVersion(): Promise<string | null>;
15
+ /** `bun add -g catherd-cli@<version>`, with its output */
16
+ install(version: string): Promise<{ ok: boolean; output: string }>;
17
+ }
18
+
19
+ export type GlobalInstall =
20
+ | { state: "current" }
21
+ | { state: "installed" }
22
+ /** installed, but the `catherd` first on PATH is another version (or none), so the launcher still runs bunx */
23
+ | { state: "shadowed"; onPath: string | null }
24
+ | { state: "failed"; reason: string };
25
+
26
+ async function run(cmd: string[]): Promise<{ ok: boolean; stdout: string; output: string }> {
27
+ const p = Bun.spawn(cmd, {
28
+ env: scrubSecrets(process.env),
29
+ stdin: "ignore",
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ const [out, err, code] = await Promise.all([
34
+ new Response(p.stdout).text(),
35
+ new Response(p.stderr).text(),
36
+ p.exited,
37
+ ]);
38
+ return { ok: code === 0, stdout: out, output: `${err}${out}` };
39
+ }
40
+
41
+ /** what `bin --version` prints on stdout (a warning on stderr must not make it look like another version) */
42
+ async function versionOf(bin: string | null): Promise<string | null> {
43
+ if (!bin) return null;
44
+ const r = await run([bin, "--version"]).catch(() => null);
45
+ return r?.ok ? r.stdout.trim() || null : null;
46
+ }
47
+
48
+ /**
49
+ * bun's global bin folder: `bun pm bin -g`, else where bun puts it by default. `bun pm bin -g` fails
50
+ * (Bun 1.4.2) while the global folder exists without a package.json, which is before the first global install.
51
+ */
52
+ async function globalBin(): Promise<string> {
53
+ const r = await run([process.execPath, "pm", "bin", "-g"]).catch(() => null);
54
+ const dir = r?.ok ? r.stdout.trim() : "";
55
+ if (dir) return dir;
56
+ return process.env.BUN_INSTALL_BIN || join(process.env.BUN_INSTALL || join(homedir(), ".bun"), "bin");
57
+ }
58
+
59
+ /** `bunx` runs a package from `<tmp>/bunx-<uid>-<package>@<tag>/node_modules/.bin`, prepended to PATH */
60
+ const isBunxBin = (dir: string) => dir.split(/[\\/]/).some((part) => part.startsWith("bunx-"));
61
+
62
+ export const realGlobalInstall: GlobalInstallDeps = {
63
+ async globalVersion() {
64
+ return versionOf(Bun.which("catherd", { PATH: await globalBin() }));
65
+ },
66
+ async pathVersion() {
67
+ const PATH = (process.env.PATH ?? "")
68
+ .split(delimiter)
69
+ .filter((dir) => dir && !isBunxBin(dir))
70
+ .join(delimiter);
71
+ return versionOf(Bun.which("catherd", { PATH }));
72
+ },
73
+ install: (version) =>
74
+ run([process.execPath, "add", "-g", `catherd-cli@${version}`]).catch((e: unknown) => ({
75
+ ok: false,
76
+ output: errorMessage(e),
77
+ })),
78
+ };
79
+
80
+ /**
81
+ * Spec 1.1 §12: the global CLI at `version`, so the plugin's launcher starts it without a bunx resolve.
82
+ * `installing` runs before anything is resolved, so the user sees why the next seconds pass.
83
+ */
84
+ export async function ensureGlobal(
85
+ version: string,
86
+ deps: GlobalInstallDeps,
87
+ installing: () => void,
88
+ ): Promise<GlobalInstall> {
89
+ if ((await deps.globalVersion()) === version) return { state: "current" };
90
+ installing();
91
+ // never rejects: a failed install is reported, and init goes on
92
+ const r = await deps.install(version).catch((e: unknown) => ({ ok: false, output: errorMessage(e) }));
93
+ if (r.ok) {
94
+ const onPath = await deps.pathVersion().catch(() => null);
95
+ return onPath === version ? { state: "installed" } : { state: "shadowed", onPath };
96
+ }
97
+ const reason = r.output.split("\n").find((l) => l.trim()) ?? "bun add -g failed";
98
+ return { state: "failed", reason: reason.trim() };
99
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { CatherdError, isCatherdError } from "../domain/errors.ts";
5
+ import type { RouteSource } from "../domain/route.ts";
6
+ import {
7
+ canonicalJson,
8
+ type JevAnswers,
9
+ type JevFile,
10
+ JevFileSchema,
11
+ parseReply,
12
+ questionSetId,
13
+ requestKey,
14
+ type SetName,
15
+ sha256,
16
+ } from "../domain/jev.ts";
17
+ import { assetPath } from "../infra/assets.ts";
18
+ import { type JevTransport, jevRequest } from "../infra/jev-client.ts";
19
+ import { addSecret, log } from "../infra/log.ts";
20
+ import { configDir } from "../infra/paths.ts";
21
+ import { appendJsonl, ensureJsonlHeader, readJsonl, readVersioned, writeJsonAtomic } from "../infra/store.ts";
22
+
23
+ let file: JevFile | null = null;
24
+ /** catalog/jev.json: the question sets and their rules (spec §5.5). */
25
+ export const jevQuestions = (): JevFile =>
26
+ (file ??= readVersioned(assetPath("catalog/jev.json"), JevFileSchema, 1));
27
+
28
+ export const credentialsPath = (): string => join(configDir(), "credentials.json");
29
+ const CredentialsSchema = z.looseObject({
30
+ schema: z.literal(1).default(1),
31
+ typesafeApiKey: z.string().optional(),
32
+ });
33
+
34
+ /** Registers the saved Jev key with the redactor, for a process that reads logs without ever calling Jev. */
35
+ export function registerSavedSecrets(): void {
36
+ addSecret(jevKey());
37
+ }
38
+
39
+ /** How to repair a credentials file catherd cannot read: `init` alone refuses to overwrite it. */
40
+ const credentialsFix = (): string =>
41
+ `delete ${credentialsPath()} and run catherd init, or write it as {"schema": 1, "typesafeApiKey": "<your key>"}`;
42
+ const readCredentials = () =>
43
+ readVersioned(credentialsPath(), CredentialsSchema, 1, { fix: credentialsFix() });
44
+
45
+ /** The key saved in credentials.json, or why that file cannot be read (unparsable, newer schema). */
46
+ export function savedJevKey(): { key: string | null; problem: CatherdError | null } {
47
+ if (!existsSync(credentialsPath())) return { key: null, problem: null };
48
+ try {
49
+ return { key: readCredentials().typesafeApiKey?.trim() || null, problem: null };
50
+ } catch (e) {
51
+ return {
52
+ key: null,
53
+ problem: isCatherdError(e)
54
+ ? e
55
+ : new CatherdError("E_CONFIG_INVALID", String(e), { fix: credentialsFix() }),
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Spec §5.5: `TYPESAFE_API_KEY`, else `<config>/credentials.json`; null when neither has one. Jev is
62
+ * optional (D1), so a credentials file that cannot be read means no key here; it is logged, and
63
+ * `catherd doctor` says why.
64
+ */
65
+ export function jevKey(): string | null {
66
+ const env = process.env.TYPESAFE_API_KEY?.trim();
67
+ if (env) return env;
68
+ const { key, problem } = savedJevKey();
69
+ if (problem) log("warn", "jev", { error: `no key: ${problem.message}` });
70
+ return key;
71
+ }
72
+
73
+ /**
74
+ * Keeps any other credential, and the file at mode 600 (spec §10.4). A missing file starts empty; an
75
+ * unreadable or newer-schema one is refused (it throws) rather than overwritten.
76
+ */
77
+ export function saveJevKey(key: string): void {
78
+ const cur: z.infer<typeof CredentialsSchema> = existsSync(credentialsPath())
79
+ ? readCredentials()
80
+ : { schema: 1 };
81
+ writeJsonAtomic(credentialsPath(), { ...cur, schema: 1, typesafeApiKey: key.trim() }, { mode: 0o600 });
82
+ }
83
+
84
+ /** A key works when Jev lists its models: no inference, and no dependence on a question set. */
85
+ export async function testJevKey(key: string, o: JevTransport = {}): Promise<boolean> {
86
+ return (await jevRequest("GET", "/models", key, undefined, o)).ok;
87
+ }
88
+
89
+ export interface JevOpts extends JevTransport {
90
+ /** a key to use instead of jevKey(); null means none */
91
+ key?: string | null;
92
+ }
93
+
94
+ /** One jev.jsonl row (spec §5.5): never the state, only its hash. */
95
+ export interface JevRow {
96
+ at: string;
97
+ call: SetName;
98
+ lane: string | null;
99
+ questionSet: string;
100
+ key: string;
101
+ stateHash: string;
102
+ model: string | null;
103
+ requestId: string | null;
104
+ usage: { input_tokens: number; output_tokens: number } | null;
105
+ latencyMs: number | null;
106
+ attempts: number;
107
+ cached: boolean;
108
+ answers: JevAnswers | null;
109
+ derived: Record<string, unknown> | null;
110
+ used: string;
111
+ source: RouteSource;
112
+ why: string;
113
+ }
114
+
115
+ export interface Asked {
116
+ answers: JevAnswers | null;
117
+ /** why Jev gave no answers ("no key", "http 401", …), or a note on answers from an unpinned model */
118
+ why: string | null;
119
+ meta: Omit<JevRow, "at" | "answers" | "derived" | "used" | "source" | "why" | "call" | "lane">;
120
+ }
121
+
122
+ const jevLog = (runDir: string) => join(runDir, "jev.jsonl");
123
+
124
+ export function logJev(runDir: string, row: Omit<JevRow, "at">): void {
125
+ const f = jevLog(runDir);
126
+ ensureJsonlHeader(f, "jev");
127
+ appendJsonl(f, { at: new Date().toISOString(), ...row });
128
+ }
129
+
130
+ /**
131
+ * Asks one question set about `state`. A request this run already had answered (same model, state and
132
+ * questions) is answered from jev.jsonl, so a re-routed lane gets the same decision.
133
+ */
134
+ export async function askJev(runDir: string, set: SetName, state: unknown, o: JevOpts = {}): Promise<Asked> {
135
+ const f = jevQuestions();
136
+ const questions = f.sets[set].questions;
137
+ const key = requestKey(f.model, state, questions);
138
+ const meta: Asked["meta"] = {
139
+ questionSet: questionSetId(f, set),
140
+ key,
141
+ stateHash: sha256(canonicalJson(state)),
142
+ model: null,
143
+ requestId: null,
144
+ usage: null,
145
+ latencyMs: null,
146
+ attempts: 0,
147
+ cached: false,
148
+ };
149
+ // a logged answer is checked like a fresh one: a row edited by hand, or cut short, is asked again
150
+ for (const hit of readJsonl<Partial<JevRow>>(jevLog(runDir)).rows) {
151
+ if (hit.key !== key || !hit.answers) continue;
152
+ const cached = parseReply(questions, { model: hit.model ?? "", answers: hit.answers });
153
+ if (!cached.ok) continue;
154
+ const model = hit.model ?? null;
155
+ return { answers: cached.answers, why: drift(model, f.model), meta: { ...meta, model, cached: true } };
156
+ }
157
+ const apiKey = o.key === undefined ? jevKey() : o.key;
158
+ if (!apiKey) return { answers: null, why: "no key", meta };
159
+ addSecret(apiKey);
160
+ const res = await jevRequest("POST", "/systemone", apiKey, { model: f.model, state, questions }, o);
161
+ log(res.ok ? "info" : "warn", "jev", {
162
+ set,
163
+ questionSet: meta.questionSet,
164
+ stateHash: meta.stateHash,
165
+ requestId: res.ok ? res.requestId : null,
166
+ latencyMs: res.latencyMs,
167
+ attempts: res.attempts,
168
+ ...(res.ok ? {} : { error: res.error }),
169
+ });
170
+ meta.latencyMs = res.latencyMs;
171
+ meta.attempts = res.attempts;
172
+ if (!res.ok) return { answers: null, why: res.error, meta };
173
+ meta.requestId = res.requestId;
174
+ const parsed = parseReply(questions, res.body);
175
+ if (!parsed.ok) return { answers: null, why: parsed.error, meta };
176
+ meta.model = parsed.model;
177
+ meta.usage = parsed.usage;
178
+ return { answers: parsed.answers, why: drift(parsed.model, f.model), meta };
179
+ }
180
+
181
+ /** The note on answers from a model other than the pinned one, or null. */
182
+ const drift = (model: string | null, pinned: string): string | null =>
183
+ model === null || model === pinned ? null : `answered by ${model}, not the pinned ${pinned}`;
@@ -0,0 +1,390 @@
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 { assertLaneHeader, type Difficulty, type 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 { laneFile } from "./admission.ts";
18
+ import { budgetOf } from "./budget.ts";
19
+ import {
20
+ isDocPath,
21
+ isSourcePath,
22
+ landedMilestones,
23
+ fullCommit,
24
+ milestoneFiles,
25
+ milestoneStart,
26
+ reviewerPassed,
27
+ milestoneVerifier,
28
+ } from "./milestones.ts";
29
+ import type { Deps, Verdict } from "./ports.ts";
30
+ import {
31
+ appendLedger,
32
+ appendOutcome,
33
+ appendRoute,
34
+ findRun,
35
+ knowledgeFile,
36
+ readRoutes,
37
+ type Run,
38
+ runFile,
39
+ runPaths,
40
+ } from "./run-store.ts";
41
+ import { writeDigest } from "./protocol.ts";
42
+ import { openQuestions } from "./questions.ts";
43
+ import { type Notes, type NotesPatch, refreshState } from "./state.ts";
44
+ import { appendPrivate } from "../infra/store.ts";
45
+
46
+ const withHints = (hints: string[]) => (hints.length ? { hints } : {});
47
+
48
+ export interface RouteResult {
49
+ lane: string | null;
50
+ role: Role;
51
+ rung: string;
52
+ ladder: string[];
53
+ source: RouteSource;
54
+ kind: Kind | null;
55
+ difficulty: Difficulty | null;
56
+ backend: string;
57
+ /** the native agent to run a `claude:` rung as */
58
+ agent: string | null;
59
+ /** the Jev question set asked, and what it said, when Jev was asked */
60
+ questionSet: string | null;
61
+ jev: RouteJev | null;
62
+ }
63
+
64
+ function readLaneFile(run: Run, path: string): { lane: string; text: string } {
65
+ const file = runFile(run, path, "read");
66
+ const [folder, name, ...deeper] = relative(run.dir, file).split(sep);
67
+ const lane = folder === "lanes" && deeper.length === 0 && name?.endsWith(".md") ? name.slice(0, -3) : null;
68
+ if (!lane || !ID_PATTERN.test(lane))
69
+ throw new CatherdError("E_LANE_INVALID", `a lane file lives at lanes/<id>.md, not ${path}`, {
70
+ fix: "pass lane_file as lanes/Mx.Ly.md",
71
+ });
72
+ if (!existsSync(file))
73
+ throw new CatherdError("E_LANE_INVALID", `no lane file ${path}`, {
74
+ fix: "write it with write_run_file first",
75
+ });
76
+ const text = readFileSync(file, "utf8");
77
+ // spec 1.1 §6: a lane routes only on values the catalog knows
78
+ assertLaneHeader(text, `lanes/${lane}.md`);
79
+ return { lane, text };
80
+ }
81
+
82
+ /** Spec §5.4 through the routing port; a lane's route is recorded in routes.jsonl. */
83
+ export async function route(
84
+ deps: Deps,
85
+ i: { run: string; laneFile?: string; role: Role },
86
+ ): Promise<RouteResult> {
87
+ const run = findRun(i.run);
88
+ const lane = i.laneFile === undefined ? null : readLaneFile(run, i.laneFile);
89
+ const profile = deps.profiles.forRepo(run.meta.repo);
90
+ const a = await deps.routing.route({
91
+ runDir: run.dir,
92
+ repo: run.meta.repo,
93
+ profile,
94
+ role: i.role,
95
+ lane: lane?.lane ?? null,
96
+ laneText: lane?.text ?? null,
97
+ spentFraction: budgetOf(run, profile.budget, deps.now())?.fraction ?? 0,
98
+ });
99
+ if (lane)
100
+ appendRoute(run, {
101
+ at: new Date(deps.now()).toISOString(),
102
+ lane: lane.lane,
103
+ role: i.role,
104
+ rung: a.rung,
105
+ ladder: a.ladder,
106
+ source: "route",
107
+ decidedBy: a.source,
108
+ from: null,
109
+ reason: null,
110
+ kind: a.kind,
111
+ difficulty: a.difficulty,
112
+ questionSet: a.questionSet,
113
+ jev: a.jev,
114
+ });
115
+ return {
116
+ lane: lane?.lane ?? null,
117
+ role: i.role,
118
+ ...a,
119
+ backend: parseRung(a.rung).backend,
120
+ agent: deps.profiles.agentFor(run.meta.repo, i.role, a.rung),
121
+ };
122
+ }
123
+
124
+ /** Evidence that the lane's own ownership is the problem: a design question, never a capability one. */
125
+ const OWNERSHIP = /outside (the )?lane('s)? ownership|owned by/i;
126
+
127
+ const CLIMB_DESIGN_FIX = "send it to the architect (ask/architect delta), not up the ladder";
128
+
129
+ /**
130
+ * Spec 1.1 §9: a climb is for capability. Evidence that points at the plan (a contradiction, a file the
131
+ * lane does not own, a cross-lane interface) goes to the architect: ownership evidence on a `blocked`
132
+ * climb is refused outright; with Jev on, its `finding` answer `design` refuses any climb with evidence.
133
+ * A climb the environment caused (`env`) is never a design question.
134
+ */
135
+ async function refuseDesign(
136
+ deps: Deps,
137
+ run: Run,
138
+ i: { lane: string; reason: ClimbReason; evidence?: string; env?: boolean },
139
+ ): Promise<void> {
140
+ if (!i.evidence || i.env) return;
141
+ if (i.reason === "blocked" && OWNERSHIP.test(i.evidence))
142
+ throw new CatherdError(
143
+ "E_CLIMB_DESIGN",
144
+ `climb ${i.lane}: the evidence is about lane ownership, which a higher rung cannot fix`,
145
+ { fix: CLIMB_DESIGN_FIX },
146
+ );
147
+ const use = deps.profiles.forRepo(run.meta.repo).jev.use;
148
+ if (use === "off") return;
149
+ const file = laneFile(run, i.lane);
150
+ if (!existsSync(file)) return;
151
+ const v = await deps.routing.finding(run.dir, readFileSync(file, "utf8"), i.evidence, use);
152
+ if (v.value === "design")
153
+ throw new CatherdError("E_CLIMB_DESIGN", `climb ${i.lane}: Jev calls the evidence a design finding`, {
154
+ fix: CLIMB_DESIGN_FIX,
155
+ });
156
+ }
157
+
158
+ /** Spec §4.5: one rung up the lane's ladder, on a fresh thread; the reason goes to routes.jsonl. */
159
+ export async function climb(
160
+ deps: Deps,
161
+ i: { run: string; lane: string; reason: ClimbReason; evidence?: string; env?: boolean },
162
+ ): Promise<{
163
+ lane: string;
164
+ rung: string;
165
+ top: boolean;
166
+ backend: string;
167
+ agent: string | null;
168
+ hints?: string[];
169
+ }> {
170
+ const run = findRun(i.run);
171
+ assertId("lane", i.lane);
172
+ const unrouted = () =>
173
+ new CatherdError("E_LANE_INVALID", `lane ${i.lane} was never routed`, {
174
+ fix: `route(run, "lanes/${i.lane}.md") first`,
175
+ });
176
+ // an unrouted lane is refused before Jev is asked about its evidence (no call, no jev.jsonl row)
177
+ if (!currentRoute(readRoutes(run), i.lane)) throw unrouted();
178
+ await refuseDesign(deps, run, i);
179
+ const { cur, next } = await withFileLock(runPaths(run.dir).routes, () => {
180
+ const cur = currentRoute(readRoutes(run), i.lane);
181
+ if (!cur) throw unrouted();
182
+ const next = nextRung(cur.ladder, cur.rung);
183
+ appendRoute(run, {
184
+ ...cur,
185
+ at: new Date(deps.now()).toISOString(),
186
+ source: "climb",
187
+ from: cur.rung,
188
+ rung: next ?? cur.rung,
189
+ reason: i.evidence ? `${i.reason}: ${i.evidence}` : i.reason,
190
+ env: i.env === true,
191
+ });
192
+ // spec §5.6: a climb past the top rung ends the lane open
193
+ if (!next) {
194
+ const o = laneOutcome(readRoutes(run), i.lane, false, new Date(deps.now()).toISOString());
195
+ if (o) appendOutcome(run, o);
196
+ }
197
+ return { cur, next };
198
+ });
199
+ const { hints } = await refreshState(run, {
200
+ next: next
201
+ ? `dispatch ${i.lane} at ${next} on a fresh thread`
202
+ : `${i.lane} failed on its top rung: ask finding, then the architect or the report`,
203
+ });
204
+ const rung = next ?? cur.rung;
205
+ return {
206
+ lane: i.lane,
207
+ rung,
208
+ top: next === null,
209
+ backend: parseRung(rung).backend,
210
+ agent: next ? deps.profiles.agentFor(run.meta.repo, cur.role, next) : null,
211
+ ...withHints(hints),
212
+ };
213
+ }
214
+
215
+ const cell = (s: string) => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
216
+
217
+ export const LAND_SKIPS = ["docs-only", "no-code"] as const;
218
+ export type LandSkip = (typeof LAND_SKIPS)[number];
219
+
220
+ /**
221
+ * Spec 1.1 §6: a milestone lands only with a reviewer record and a verifier verdict since its lanes
222
+ * started, or with a `skip` the commit range bears out. Throws E_LAND_GATE naming what is missing.
223
+ */
224
+ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefined): Promise<void> {
225
+ // spec 1.1 §8: a parked milestone waits on the owner, whatever else it has
226
+ const question = openQuestions(run).find((q) => q.milestone === m);
227
+ if (question)
228
+ throw new CatherdError(
229
+ "E_LAND_GATE",
230
+ `land ${m}: it is parked, waiting on the owner: ${question.question}`,
231
+ {
232
+ fix: `when the owner answers, call answer(run, "${m}", <their answer>), finish ${m}, then land it`,
233
+ },
234
+ );
235
+ if (skip) {
236
+ // the range a skip is judged on must be the milestone as it stands: an older commit would leave
237
+ // later source commits unreviewed
238
+ const [landed, head] = await Promise.all([fullCommit(run, commit), fullCommit(run, "HEAD")]);
239
+ if (landed !== head)
240
+ throw new CatherdError(
241
+ "E_LAND_GATE",
242
+ `land ${m}: skip "${skip}" refused: ${commit} is not HEAD (${head.slice(0, 7)}): commits after it would land unreviewed`,
243
+ {
244
+ fix: `land ${m} with HEAD (${head.slice(0, 7)}) once it holds the milestone; if source changed, run reviewer-${m} and the verifier and land without skip`,
245
+ },
246
+ );
247
+ const files = await milestoneFiles(run, commit);
248
+ // an empty range lands nothing: the milestone's work is most likely not committed yet
249
+ if (files.length === 0) {
250
+ const last = landedMilestones(run).at(-1);
251
+ throw new CatherdError(
252
+ "E_LAND_GATE",
253
+ `land ${m}: skip "${skip}" refused: ${last ? `the commit range changed nothing since ${last} landed` : `${commit} changed nothing`}`,
254
+ { fix: `commit the milestone first, then land ${m} with that commit` },
255
+ );
256
+ }
257
+ const against =
258
+ skip === "docs-only" ? files.filter((f) => !isDocPath(f)) : files.filter((f) => isSourcePath(f));
259
+ if (against.length === 0) return;
260
+ const shown = `${against.slice(0, 5).join(", ")}${against.length > 5 ? `, and ${against.length - 5} more` : ""}`;
261
+ throw new CatherdError(
262
+ "E_LAND_GATE",
263
+ `land ${m}: skip "${skip}" refused: ${skip === "docs-only" ? "files outside the docs changed" : "source files changed"}: ${shown}`,
264
+ { fix: `run the reviewer (reviewer-${m}) and the verifier on ${m}, then land it without skip` },
265
+ );
266
+ }
267
+ const start = milestoneStart(run, m);
268
+ // the latest verifier attempt, passed or not: a FAIL after a PASS undoes it
269
+ const verdict = milestoneVerifier(run, m, start);
270
+ const missing = [
271
+ ...(reviewerPassed(run, m, start)
272
+ ? []
273
+ : [
274
+ `a reviewer record (a dispatch named reviewer-${m}, or record_agent_run with role reviewer and that name, status ok)`,
275
+ ]),
276
+ ...(verdict?.passed
277
+ ? []
278
+ : [
279
+ `a verifier verdict (record_agent_run with role verifier and a name holding ${m}, status ok; a headless verifier's reply opening VERDICT: PASS)${verdict ? `: the latest, ${verdict.name}${verdict.headless ? " (headless)" : ""}, is ${verdict.verdict}` : ""}`,
280
+ ]),
281
+ ];
282
+ if (missing.length)
283
+ throw new CatherdError(
284
+ "E_LAND_GATE",
285
+ `land ${m}: missing ${missing.join(" and ")}, since its lanes started`,
286
+ {
287
+ fix: `run reviewer-${m} (a dispatch, or a Claude subagent recorded with record_agent_run(name: "reviewer-${m}")) and the verifier on ${m}, recording it with record_agent_run(name: "verifier-${m}") (a FAIL with status "failed"), then land again; a docs-only milestone passes skip: "docs-only"`,
288
+ },
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Spec §4.7: the full five-column ledger row, with the minutes since the previous landing (or the
294
+ * run's start), and `learned` appended to the repo's knowledge.md.
295
+ */
296
+ export async function land(
297
+ deps: Deps,
298
+ i: {
299
+ run: string;
300
+ milestone: string;
301
+ what: string;
302
+ commit: string;
303
+ evidence: string;
304
+ next: string;
305
+ learned?: string;
306
+ skip?: LandSkip;
307
+ },
308
+ ): Promise<{ ledger: string; minutes: number; digest: string; hints?: string[] }> {
309
+ const run = findRun(i.run);
310
+ // the digest is named after the milestone: an id, checked before anything is written
311
+ assertId("milestone", i.milestone);
312
+ // commitExists throws E_IO_UNEXPECTED on a timeout, which reaches the caller as is
313
+ if (!/^[0-9a-f]{7,40}$/.test(i.commit) || !(await commitExists(run.meta.repo, i.commit)))
314
+ throw new CatherdError("E_RUN_COMMIT", `no commit ${i.commit} in ${run.meta.repo}`, {
315
+ fix: "commit the milestone first, then pass its hash",
316
+ });
317
+ await gate(run, i.milestone, i.commit, i.skip);
318
+ const now = new Date(deps.now());
319
+ let row = "";
320
+ let minutes = 0;
321
+ const landRow = (notes: Notes): NotesPatch => {
322
+ minutes = Math.max(
323
+ 0,
324
+ Math.round((now.getTime() - Date.parse(notes.lastLandedAt ?? run.meta.createdAt)) / 60_000),
325
+ );
326
+ row = [i.milestone, i.what, i.commit, String(minutes), i.evidence].map(cell).join(" | ");
327
+ appendLedger(run, row);
328
+ return { lastCheck: cell(i.evidence), next: i.next, lastLandedAt: now.toISOString() };
329
+ };
330
+ // on a failed refresh the notes still reach state.json, so the next landing counts its minutes from this one
331
+ const { hints } = await refreshState(run, landRow);
332
+ // spec §5.6: every routed lane of the milestone lands with it
333
+ // under the routes lock, so a racing climb cannot slip between the read and the rows
334
+ let routed: string[] = [];
335
+ let landed = 0;
336
+ await withFileLock(runPaths(run.dir).routes, () => {
337
+ const routes = readRoutes(run);
338
+ routed = [...new Set(routes.map((r) => r.lane))];
339
+ for (const lane of routed)
340
+ if (lane.startsWith(`${i.milestone}.`)) {
341
+ landed++;
342
+ const o = laneOutcome(routes, lane, true, now.toISOString());
343
+ if (o) appendOutcome(run, o);
344
+ }
345
+ });
346
+ // a milestone name no routed lane starts with is most likely a typo: say so rather than record nothing
347
+ if (routed.length > 0 && landed === 0)
348
+ hints.push(
349
+ `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`,
350
+ );
351
+ if (i.learned) {
352
+ appendPrivate(
353
+ knowledgeFile(run.meta.repo),
354
+ `- ${now.toISOString().slice(0, 10)} ${run.meta.title} ${i.milestone}: ${cell(i.learned)}\n`,
355
+ );
356
+ }
357
+ // spec 1.1 §10: the milestone's digest, which the milestone push links
358
+ const digest = writeDigest(run, {
359
+ milestone: i.milestone,
360
+ what: i.what,
361
+ commit: i.commit,
362
+ evidence: i.evidence,
363
+ minutes,
364
+ at: now.toISOString(),
365
+ });
366
+ return { ledger: row, minutes, digest, ...withHints(hints) };
367
+ }
368
+
369
+ /** Jev's `finding` or `same-defect` answer, through the routing port. */
370
+ export async function ask(
371
+ deps: Deps,
372
+ i: { run: string; question: "finding" | "same-defect"; state: Record<string, string> },
373
+ ): Promise<Verdict<string>> {
374
+ const run = findRun(i.run);
375
+ const need = (k: string): string => {
376
+ const v = i.state[k];
377
+ if (!v)
378
+ throw new CatherdError("E_INPUT_INVALID", `ask ${i.question} needs state.${k}`, {
379
+ fix: i.question === "finding" ? "pass state { lane_file, finding }" : "pass state { before, after }",
380
+ });
381
+ return v;
382
+ };
383
+ // spec §5.5: a profile with Jev off never asks it, for routing or for these verdicts
384
+ const use = deps.profiles.forRepo(run.meta.repo).jev.use;
385
+ if (i.question === "finding") {
386
+ const { text } = readLaneFile(run, need("lane_file"));
387
+ return deps.routing.finding(run.dir, text, need("finding"), use);
388
+ }
389
+ return deps.routing.sameDefect(run.dir, need("before"), need("after"), use);
390
+ }