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.
- package/README.md +141 -33
- 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 +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -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 +282 -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 +333 -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 +60 -0
- package/src/domain/failover.ts +119 -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 +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- 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 +49 -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 +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -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 +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -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 +160 -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 +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -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 +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -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 +197 -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/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -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/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -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 +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -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
|
@@ -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
|
+
}
|