catherd-cli 1.2.1 → 1.3.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 +74 -3
- package/catalog/models.json +148 -5
- package/catalog/scores.json +24 -0
- package/catalog/sources.json +12 -2
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +3 -3
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +54 -17
- package/src/adapters/all.ts +6 -0
- package/src/adapters/antigravity/events.ts +131 -0
- package/src/adapters/antigravity/home.ts +46 -0
- package/src/adapters/antigravity/index.ts +320 -0
- package/src/adapters/antigravity/models.ts +37 -0
- package/src/adapters/backend.ts +34 -1
- package/src/adapters/claude-code/index.ts +2 -0
- package/src/adapters/codex/index.ts +2 -0
- package/src/adapters/cursor/events.ts +120 -0
- package/src/adapters/cursor/home.ts +79 -0
- package/src/adapters/cursor/index.ts +331 -0
- package/src/adapters/cursor/models.ts +30 -0
- package/src/adapters/discovery.ts +21 -0
- package/src/adapters/grok/events.ts +112 -0
- package/src/adapters/grok/home.ts +132 -0
- package/src/adapters/grok/index.ts +339 -0
- package/src/adapters/grok/models.ts +48 -0
- package/src/adapters/opencode/index.ts +2 -0
- package/src/cli.ts +1 -0
- package/src/domain/catalog.ts +10 -1
- package/src/domain/cost.ts +1 -0
- package/src/domain/errors.ts +1 -0
- package/src/domain/failover.ts +5 -2
- package/src/domain/ids.ts +2 -2
- package/src/domain/profile.ts +57 -0
- package/src/domain/record.ts +2 -0
- package/src/domain/util.ts +3 -0
- package/src/entry/knowledge-command.ts +50 -0
- package/src/entry/tui/effects.ts +12 -3
- package/src/entry/tui/fixtures.ts +1 -0
- package/src/entry/tui/profile-tree.ts +10 -1
- package/src/entry/tui/views/profiles.tsx +14 -1
- package/src/infra/paths.ts +3 -1
- package/src/services/admission.ts +27 -4
- package/src/services/backends.ts +42 -4
- package/src/services/capture.ts +112 -54
- package/src/services/catalog-refresh.ts +2 -1
- package/src/services/dispatches.ts +2 -0
- package/src/services/doctor-access.ts +14 -7
- package/src/services/doctor-backends.ts +10 -1
- package/src/services/doctor-checks.ts +25 -0
- package/src/services/doctor.ts +3 -0
- package/src/services/finalize.ts +1 -0
- package/src/services/lane-service.ts +3 -10
- package/src/services/profile-service.ts +3 -3
- package/src/services/profile-store.ts +106 -2
- package/src/services/run-service.ts +37 -4
- package/src/services/run-store.ts +11 -1
- package/src/services/source-derive.ts +32 -7
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
scoresOf,
|
|
13
13
|
} from "../domain/catalog.ts";
|
|
14
14
|
import { DIFFICULTIES, type Difficulty, KINDS, type Kind } from "../domain/lane.ts";
|
|
15
|
-
import type
|
|
15
|
+
import { defaultEffortOf, type SourcesFile } from "../domain/sources.ts";
|
|
16
16
|
import type { SourceTransport } from "../infra/sources/http.ts";
|
|
17
17
|
import { adjacent, derive, type RawAnswers } from "./source-derive.ts";
|
|
18
18
|
import { cachedAnswers, syncSources } from "./source-sync.ts";
|
|
@@ -72,6 +72,7 @@ export function rebuildShipped(
|
|
|
72
72
|
direct.filter((s) => s.confidence !== "inferred"),
|
|
73
73
|
new Set(),
|
|
74
74
|
ctx.now,
|
|
75
|
+
(f) => defaultEffortOf(ctx.sources, f),
|
|
75
76
|
);
|
|
76
77
|
const scores = best([...direct, ...spread], ctx.now);
|
|
77
78
|
const benchmark = (d: Dim) => {
|
|
@@ -27,6 +27,8 @@ const AdmitSchema = z.looseObject({
|
|
|
27
27
|
failoverOf: z.string().optional(),
|
|
28
28
|
access: z.enum(ACCESS),
|
|
29
29
|
isolated: z.boolean(),
|
|
30
|
+
/** the role's network grant (1.1 §5) when admitted; absent before 1.3 */
|
|
31
|
+
network: z.boolean().optional(),
|
|
30
32
|
cliVersion: z.string().nullable(),
|
|
31
33
|
admittedAt: z.string(),
|
|
32
34
|
repo: z.string(),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realTmpdir } from "../adapters/access.ts";
|
|
2
|
-
import type { AccessShell } from "../adapters/backend.ts";
|
|
2
|
+
import type { AccessProbeId, AccessShell } from "../adapters/backend.ts";
|
|
3
3
|
import { adapterFor } from "../adapters/registry.ts";
|
|
4
4
|
import "../adapters/all.ts";
|
|
5
5
|
import { tryParseRung } from "../domain/ids.ts";
|
|
@@ -12,7 +12,7 @@ import type { Check } from "./doctor-checks.ts";
|
|
|
12
12
|
// Spec §5 and §12: the five access probes doctor runs per backend that serves a workspace-write role, each
|
|
13
13
|
// in that backend's worker shell with the grants a worker gets, and the rows that report them.
|
|
14
14
|
|
|
15
|
-
export type ProbeId =
|
|
15
|
+
export type ProbeId = AccessProbeId;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* What the probes reach: npm's ping, through HTTPS_PROXY when it is set (Bun's fetch honours it), and
|
|
@@ -131,8 +131,13 @@ export async function runProbes(shell: AccessShell, network: boolean): Promise<P
|
|
|
131
131
|
return out;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
134
|
+
/**
|
|
135
|
+
* The fix for a failed probe: the sandbox's own when its shell names one; Codex's sandbox holds catherd's grants;
|
|
136
|
+
* the others run unsandboxed.
|
|
137
|
+
*/
|
|
138
|
+
function fixFor(backend: string, id: ProbeId, shell: AccessShell): string {
|
|
139
|
+
const own = shell.fixes?.[id];
|
|
140
|
+
if (own) return own;
|
|
136
141
|
if (backend === "codex") {
|
|
137
142
|
const override =
|
|
138
143
|
"catherd passes this grant to Codex itself (with your top-level [sandbox_workspace_write] writable_roots added): check that no --profile or managed requirements.toml overrides it";
|
|
@@ -173,7 +178,7 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
|
|
|
173
178
|
const checks: Check[] = [];
|
|
174
179
|
for (const [id, network] of workspaceWriteNetwork(profiles)) {
|
|
175
180
|
const a = adapterFor(id);
|
|
176
|
-
if (!a
|
|
181
|
+
if (!a) continue;
|
|
177
182
|
if (!installed.has(id)) {
|
|
178
183
|
checks.push({
|
|
179
184
|
id: `access:${id}`,
|
|
@@ -184,7 +189,9 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
|
|
|
184
189
|
});
|
|
185
190
|
continue;
|
|
186
191
|
}
|
|
187
|
-
const shell =
|
|
192
|
+
const shell = a.accessShell
|
|
193
|
+
? await a.accessShell({ network }).catch((e: unknown) => String(e))
|
|
194
|
+
: `no way to run a shell in ${id}'s sandbox without a model turn; the live kit (docs/dev/live-verification.md) runs the five probes as one worker turn`;
|
|
188
195
|
if (id === "codex")
|
|
189
196
|
checks.push(
|
|
190
197
|
typeof shell === "string"
|
|
@@ -217,7 +224,7 @@ export async function accessChecks(profiles: Profile[], installed: ReadonlySet<s
|
|
|
217
224
|
state: "warn",
|
|
218
225
|
word: "blocked",
|
|
219
226
|
detail: `in ${shell.how}, a worker cannot: ${failed.map((r) => `${r.label} (${r.why})`).join("; ")}`,
|
|
220
|
-
fix: failed.map((r) => `${r.label}: ${fixFor(id, r.id)}`).join("\n"),
|
|
227
|
+
fix: failed.map((r) => `${r.label}: ${fixFor(id, r.id, shell)}`).join("\n"),
|
|
221
228
|
}
|
|
222
229
|
: {
|
|
223
230
|
...base,
|
|
@@ -4,6 +4,7 @@ import "../adapters/all.ts";
|
|
|
4
4
|
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
5
5
|
import type { Profile } from "../domain/profile.ts";
|
|
6
6
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
7
|
+
import { probeBackend } from "./backends.ts";
|
|
7
8
|
import { refreshDiscovery } from "./catalog-service.ts";
|
|
8
9
|
import { type Check, errText, fixOf } from "./doctor-checks.ts";
|
|
9
10
|
|
|
@@ -30,6 +31,7 @@ const PROBLEM_WORD: Record<string, string> = {
|
|
|
30
31
|
E_BACKEND_MISSING: "missing",
|
|
31
32
|
E_BACKEND_TOO_OLD: "too old",
|
|
32
33
|
E_BACKEND_NOT_LOGGED_IN: "not logged in",
|
|
34
|
+
E_BACKEND_CANNOT_RUN: "cannot run",
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -91,7 +93,7 @@ export async function backendChecks(
|
|
|
91
93
|
for (const id of ADAPTER_IDS) {
|
|
92
94
|
const a = adapterFor(id);
|
|
93
95
|
if (!a) continue;
|
|
94
|
-
const probe: Probe = await a
|
|
96
|
+
const probe: Probe = await probeBackend(a).catch((e: unknown) => ({
|
|
95
97
|
installed: false,
|
|
96
98
|
version: null,
|
|
97
99
|
versionOk: false,
|
|
@@ -101,6 +103,9 @@ export async function backendChecks(
|
|
|
101
103
|
],
|
|
102
104
|
}));
|
|
103
105
|
if (probe.installed) installed?.add(id);
|
|
106
|
+
// spec 1.3 §4.7: what the probe found worth knowing, nothing to fix (an `agent` on PATH that is not Cursor)
|
|
107
|
+
for (const i of probe.info ?? [])
|
|
108
|
+
checks.push({ id: i.id, label: i.label, state: "info", word: "info", detail: i.detail });
|
|
104
109
|
const problem = probe.problems[0];
|
|
105
110
|
const use = used.get(id);
|
|
106
111
|
if (!problem) {
|
|
@@ -123,6 +128,10 @@ export async function backendChecks(
|
|
|
123
128
|
}
|
|
124
129
|
: { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
|
|
125
130
|
);
|
|
131
|
+
// spec 1.3 §6.6: asked only of a logged-in CLI (a logged-out agy -p would open a browser)
|
|
132
|
+
const quota = probe.loggedIn && a.quota ? await a.quota().catch(() => null) : null;
|
|
133
|
+
if (quota)
|
|
134
|
+
checks.push({ id: `quota:${id}`, label: `${id} quota`, state: "info", word: "quota", detail: quota });
|
|
126
135
|
continue;
|
|
127
136
|
}
|
|
128
137
|
checks.push({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
4
|
+
import type { Profile } from "../domain/profile.ts";
|
|
4
5
|
import { claudeHome, locksDir } from "../infra/paths.ts";
|
|
5
6
|
import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
|
|
6
7
|
import { agentLinkState } from "./agent-links.ts";
|
|
@@ -146,3 +147,27 @@ export function agentsCheck(): Check {
|
|
|
146
147
|
? { ...base, state: "ok", word: "ready", detail: `${links.ok.length} linked` }
|
|
147
148
|
: { ...base, state: "skip", word: "none", detail: "no profile uses a native Claude rung" };
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
/** The browser CLI the ui-reviewer's prompt takes its screenshots with. */
|
|
152
|
+
const UI_BROWSER = "agent-browser";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The ui-reviewer takes screenshots with agent-browser: a profile that turns it on, on a machine without it,
|
|
156
|
+
* dispatches a role that cannot do its job. Nothing to say when it is on PATH or no profile turns the role on.
|
|
157
|
+
*/
|
|
158
|
+
export function uiBrowserCheck(profiles: Profile[], onPath: (bin: string) => string | null): Check | null {
|
|
159
|
+
const on = profiles.filter((p) => p.roles["ui-reviewer"].enabled).map((p) => p.name);
|
|
160
|
+
if (!on.length || onPath(UI_BROWSER)) return null;
|
|
161
|
+
return {
|
|
162
|
+
id: "ui-browser",
|
|
163
|
+
label: UI_BROWSER,
|
|
164
|
+
state: "warn",
|
|
165
|
+
word: "missing",
|
|
166
|
+
detail: `${UI_BROWSER} is not on PATH, so ui-reviewer (${on.join(", ")}) cannot take screenshots`,
|
|
167
|
+
fix: [
|
|
168
|
+
`npm i -g ${UI_BROWSER}`,
|
|
169
|
+
"or turn the role off:",
|
|
170
|
+
...on.map((n) => `catherd profile set roles.ui-reviewer.enabled false --profile ${n}`),
|
|
171
|
+
].join("\n"),
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/services/doctor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
locksCheck,
|
|
20
20
|
mcpCheck,
|
|
21
21
|
pluginCheck,
|
|
22
|
+
uiBrowserCheck,
|
|
22
23
|
} from "./doctor-checks.ts";
|
|
23
24
|
import { credentialsPath, jevKey, savedJevKey, testJevKey } from "./jev-service.ts";
|
|
24
25
|
import {
|
|
@@ -321,6 +322,8 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
321
322
|
detail: "the plugin's MCP launcher runs bunx when no global catherd is installed",
|
|
322
323
|
fix: "put Bun's bin folder (~/.bun/bin) on PATH",
|
|
323
324
|
});
|
|
325
|
+
const browser = uiBrowserCheck(mine, onPath);
|
|
326
|
+
if (browser) checks.push(browser);
|
|
324
327
|
let corrupt: ReturnType<typeof listRuns>["corrupt"] = [];
|
|
325
328
|
try {
|
|
326
329
|
corrupt = listRuns().corrupt;
|
package/src/services/finalize.ts
CHANGED
|
@@ -222,6 +222,7 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
222
222
|
threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
|
|
223
223
|
access: a.access,
|
|
224
224
|
isolated: a.isolated,
|
|
225
|
+
...(a.network === undefined ? {} : { network: a.network }),
|
|
225
226
|
images: o.images,
|
|
226
227
|
error: o.error,
|
|
227
228
|
replyPath: relative(run.dir, p.reply),
|
|
@@ -4,6 +4,7 @@ import { CatherdError } from "../domain/errors.ts";
|
|
|
4
4
|
import { assertId, ID_PATTERN, parseRung } from "../domain/ids.ts";
|
|
5
5
|
import { assertLaneHeader, type Difficulty, type Kind } from "../domain/lane.ts";
|
|
6
6
|
import type { Role } from "../domain/roles.ts";
|
|
7
|
+
import { cell } from "../domain/util.ts";
|
|
7
8
|
import {
|
|
8
9
|
type ClimbReason,
|
|
9
10
|
currentRoute,
|
|
@@ -28,11 +29,11 @@ import {
|
|
|
28
29
|
} from "./milestones.ts";
|
|
29
30
|
import type { Deps, Verdict } from "./ports.ts";
|
|
30
31
|
import {
|
|
32
|
+
appendKnowledge,
|
|
31
33
|
appendLedger,
|
|
32
34
|
appendOutcome,
|
|
33
35
|
appendRoute,
|
|
34
36
|
findRun,
|
|
35
|
-
knowledgeFile,
|
|
36
37
|
readRoutes,
|
|
37
38
|
type Run,
|
|
38
39
|
runFile,
|
|
@@ -41,7 +42,6 @@ import {
|
|
|
41
42
|
import { writeDigest } from "./protocol.ts";
|
|
42
43
|
import { openQuestions } from "./questions.ts";
|
|
43
44
|
import { type Notes, type NotesPatch, refreshState } from "./state.ts";
|
|
44
|
-
import { appendPrivate } from "../infra/store.ts";
|
|
45
45
|
|
|
46
46
|
const withHints = (hints: string[]) => (hints.length ? { hints } : {});
|
|
47
47
|
|
|
@@ -212,8 +212,6 @@ export async function climb(
|
|
|
212
212
|
};
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
const cell = (s: string) => s.replace(/[|\n]/g, "/").replace(/\s+/g, " ").trim();
|
|
216
|
-
|
|
217
215
|
export const LAND_SKIPS = ["docs-only", "no-code"] as const;
|
|
218
216
|
export type LandSkip = (typeof LAND_SKIPS)[number];
|
|
219
217
|
|
|
@@ -348,12 +346,7 @@ export async function land(
|
|
|
348
346
|
hints.push(
|
|
349
347
|
`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
348
|
);
|
|
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
|
-
}
|
|
349
|
+
if (i.learned) appendKnowledge(run.meta.repo, now, `${run.meta.title} ${i.milestone}`, i.learned);
|
|
357
350
|
// spec 1.1 §10: the milestone's digest, which the milestone push links
|
|
358
351
|
const digest = writeDigest(run, {
|
|
359
352
|
milestone: i.milestone,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type ProfilePatch,
|
|
16
16
|
resolveProfile,
|
|
17
17
|
} from "../domain/profile.ts";
|
|
18
|
-
import { repairs, type Validation
|
|
18
|
+
import { repairs, type Validation } from "../domain/profile-rules.ts";
|
|
19
19
|
import { ROLES } from "../domain/roles.ts";
|
|
20
20
|
import { withFileLockSync } from "../infra/filelock.ts";
|
|
21
21
|
import { configDir } from "../infra/paths.ts";
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
readProjects,
|
|
38
38
|
requireProfile,
|
|
39
39
|
roleEnforcement,
|
|
40
|
-
|
|
40
|
+
validateHere,
|
|
41
41
|
validateNamed,
|
|
42
42
|
} from "./profile-store.ts";
|
|
43
43
|
|
|
@@ -67,7 +67,7 @@ function saveAndLink(name: string, doc: ProfileDoc): Synced {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const validate = (doc: ProfileDoc, name: string): Validation =>
|
|
70
|
-
|
|
70
|
+
validateHere(resolveProfile(doc, name), loadCatalog({ timings: false }), doc);
|
|
71
71
|
|
|
72
72
|
/** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
|
|
73
73
|
export type Saved = ProfileSaved;
|
|
@@ -14,11 +14,13 @@ import {
|
|
|
14
14
|
ProfileDocSchema,
|
|
15
15
|
resolveProfile,
|
|
16
16
|
} from "../domain/profile.ts";
|
|
17
|
-
import { type Validation, validateProfile } from "../domain/profile-rules.ts";
|
|
17
|
+
import { type Issue, type Validation, validateProfile } from "../domain/profile-rules.ts";
|
|
18
|
+
import type { Catalog } from "../domain/catalog.ts";
|
|
18
19
|
import type { Access } from "../domain/record.ts";
|
|
19
20
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
20
21
|
import { configDir } from "../infra/paths.ts";
|
|
21
22
|
import { readJsonFile } from "../infra/store.ts";
|
|
23
|
+
import { standInFor } from "./backends.ts";
|
|
22
24
|
import { backendOfKey, loadCatalog } from "./catalog-service.ts";
|
|
23
25
|
|
|
24
26
|
// Spec §7.3, the read side of the profile service: where profiles, config.json and projects.json live,
|
|
@@ -116,12 +118,114 @@ export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter
|
|
|
116
118
|
export const keyRunnable = (key: string, backends: string[] = runnableBackends()): boolean =>
|
|
117
119
|
backends.includes(backendOfKey(key));
|
|
118
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Spec 1.3 §8: each backend `p` isolates whose isolated run logs in with an API key that is not in `env` (the
|
|
123
|
+
* environment catherd runs, and starts workers, in).
|
|
124
|
+
*/
|
|
125
|
+
export function isolationKeyErrors(
|
|
126
|
+
p: Profile,
|
|
127
|
+
env: Record<string, string | undefined> = process.env,
|
|
128
|
+
): Issue[] {
|
|
129
|
+
const out: Issue[] = [];
|
|
130
|
+
for (const [id, h] of Object.entries(p.harness)) {
|
|
131
|
+
const key = h.isolated ? adapterFor(id)?.isolationKey : undefined;
|
|
132
|
+
if (key && !env[key])
|
|
133
|
+
out.push({
|
|
134
|
+
path: `harness.${id}.isolated`,
|
|
135
|
+
message: `an isolated ${id} run needs ${key}, which catherd's environment does not have`,
|
|
136
|
+
fix: `export ${key}=<key>, or catherd profile set harness.${id}.isolated false`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Spec 1.3 §9 Q2: each enabled role whose rung, or a failover stand-in of one, runs natively on a backend that
|
|
144
|
+
* holds the role's access only when isolated (agy has no read-only flag).
|
|
145
|
+
*/
|
|
146
|
+
export function isolatedOnlyErrors(p: Profile): Issue[] {
|
|
147
|
+
const out: Issue[] = [];
|
|
148
|
+
for (const role of ROLES) {
|
|
149
|
+
const rc = p.roles[role];
|
|
150
|
+
if (!rc.enabled) continue;
|
|
151
|
+
// a stand-in the profile does not name is the one dispatch picks on its own (a paired backend's or the
|
|
152
|
+
// adapter's); one on the rung's own backend adds nothing, since the rung's own error names that backend
|
|
153
|
+
const runs = [
|
|
154
|
+
...rc.rungs.map((r) => [r, `roles.${role}.rungs`]),
|
|
155
|
+
...rc.rungs.flatMap((r) => {
|
|
156
|
+
if (p.failover[r]) return [[p.failover[r], `failover.${r}`, null]];
|
|
157
|
+
const s = standInFor(p.failover, r);
|
|
158
|
+
return s && tryParseRung(s)?.backend !== tryParseRung(r)?.backend
|
|
159
|
+
? [[s, `roles.${role}.rungs`, r]]
|
|
160
|
+
: [];
|
|
161
|
+
}),
|
|
162
|
+
] as [string, string, string | null][];
|
|
163
|
+
for (const [rung, path, auto] of runs) {
|
|
164
|
+
const b = tryParseRung(rung)?.backend;
|
|
165
|
+
if (!b || p.harness[b]?.isolated || !adapterFor(b)?.isolatedOnly?.includes(rc.access)) continue;
|
|
166
|
+
const isolate = `isolate ${b} (catherd profile set harness.${b}.isolated true)`;
|
|
167
|
+
out.push({
|
|
168
|
+
path,
|
|
169
|
+
message: `${rung}${auto ? `, the automatic stand-in of ${auto}` : ""}: native ${b} cannot hold the ${role} role to ${rc.access}`,
|
|
170
|
+
fix: auto
|
|
171
|
+
? `${isolate}, or name another stand-in (catherd profile set failover.${auto} <rung>)`
|
|
172
|
+
: `${isolate}, or put this role on another backend`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spec §4.6: with `budget.usd` set, one warning per backend an enabled role runs on whose adapter reports no
|
|
181
|
+
* dollar cost, since that cap never sees what the backend spends. Never an error: the cap still counts the rest.
|
|
182
|
+
*/
|
|
183
|
+
export function budgetUsdWarnings(p: Profile): Issue[] {
|
|
184
|
+
if (p.budget.usd === undefined) return [];
|
|
185
|
+
const blind = new Map<string, Role[]>();
|
|
186
|
+
const note = (rung: string, role: Role) => {
|
|
187
|
+
const b = tryParseRung(rung)?.backend;
|
|
188
|
+
// no adapter (native `claude`, whose subagents report their own cost): nothing to warn about
|
|
189
|
+
if (!b || adapterFor(b)?.reportsCost !== false) return;
|
|
190
|
+
const roles = blind.get(b) ?? [];
|
|
191
|
+
if (!roles.includes(role)) roles.push(role);
|
|
192
|
+
blind.set(b, roles);
|
|
193
|
+
};
|
|
194
|
+
for (const role of ROLES) {
|
|
195
|
+
const rc = p.roles[role];
|
|
196
|
+
if (!rc.enabled) continue;
|
|
197
|
+
for (const rung of rc.rungs) {
|
|
198
|
+
note(rung, role);
|
|
199
|
+
// a quota failover runs the role on its stand-in (its own, paired or the backend's), which the cap must see
|
|
200
|
+
const standIn = standInFor(p.failover, rung);
|
|
201
|
+
if (standIn) note(standIn, role);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return [...blind].map(([b, roles]) => ({
|
|
205
|
+
path: "budget.usd",
|
|
206
|
+
message: `budget.usd will not see ${b}'s spend: ${b} reports no dollar cost (${roles.join(", ")} run on it)`,
|
|
207
|
+
fix: "cap it with budget.tokens or budget.minutes: catherd profile set budget.tokens <n>",
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Spec §7.1 validation on this machine: the backends catherd can run here, the keys isolation needs, the
|
|
213
|
+
* accesses a backend holds only when isolated, and the backends a dollar budget cannot see.
|
|
214
|
+
*/
|
|
215
|
+
export function validateHere(p: Profile, c: Catalog, doc?: ProfileDoc): Validation {
|
|
216
|
+
const v = validateProfile(p, c, runnableBackends(), doc);
|
|
217
|
+
return {
|
|
218
|
+
errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
|
|
219
|
+
warnings: [...v.warnings, ...budgetUsdWarnings(p)],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
119
223
|
/** `repo`: the git toplevel whose listing route reads (opencode lists its models per repository). */
|
|
120
224
|
export function validateNamed(name?: string, repo: string | null = null): Validation {
|
|
121
225
|
const n = name ?? activeName(repo);
|
|
122
226
|
const doc = readProfileDoc(n);
|
|
123
227
|
const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
|
|
124
|
-
return
|
|
228
|
+
return validateHere(resolveProfile(doc, n), catalog, doc);
|
|
125
229
|
}
|
|
126
230
|
|
|
127
231
|
/** Spec D10: how strongly the backend holds a role to its access mode. */
|
|
@@ -18,6 +18,7 @@ import type { Deps } from "./ports.ts";
|
|
|
18
18
|
import {
|
|
19
19
|
type AgentRun,
|
|
20
20
|
appendAgentRun,
|
|
21
|
+
appendKnowledge,
|
|
21
22
|
createRun,
|
|
22
23
|
findRun,
|
|
23
24
|
knowledgeFile,
|
|
@@ -182,13 +183,45 @@ export function recordAgentRun(
|
|
|
182
183
|
return row;
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
186
|
+
/** The git toplevel of `repo`, any path inside it; refused outside a repository. */
|
|
187
|
+
async function repoTop(repo: string): Promise<string> {
|
|
187
188
|
const top = await gitToplevel(repo);
|
|
188
189
|
if (!top)
|
|
189
190
|
throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
|
|
190
191
|
fix: "pass the path of the repository",
|
|
191
192
|
});
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
return top;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What past runs of the repo learned; `repo` may be any path inside it. */
|
|
197
|
+
export async function readKnowledge(repo: string): Promise<string> {
|
|
198
|
+
const file = knowledgeFile(await repoTop(repo));
|
|
199
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
200
|
+
return text.trim() ? text : "catherd: no knowledge recorded yet for this repo";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Where the repo's knowledge.md is (it may not exist yet); `repo` may be any path inside it. */
|
|
204
|
+
export async function knowledgePath(repo: string): Promise<{ repo: string; path: string }> {
|
|
205
|
+
const top = await repoTop(repo);
|
|
206
|
+
return { repo: top, path: knowledgeFile(top) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The repo's knowledge.md as its lines, none when it is missing or blank; `repo` may be any path inside it. */
|
|
210
|
+
export async function knowledgeLines(repo: string): Promise<{ repo: string; path: string; lines: string[] }> {
|
|
211
|
+
const at = await knowledgePath(repo);
|
|
212
|
+
const text = existsSync(at.path) ? readFileSync(at.path, "utf8") : "";
|
|
213
|
+
return { ...at, lines: text.split("\n").filter((l) => l.trim()) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The source a line added by hand carries, where a landing puts its run's title and milestone. */
|
|
217
|
+
const BY_HAND = "by hand";
|
|
218
|
+
|
|
219
|
+
/** Appends one line of the user's own to the repo's knowledge.md, as `land`'s `learned` does; returns it. */
|
|
220
|
+
export async function addKnowledge(repo: string, text: string, now: Date = new Date()): Promise<string> {
|
|
221
|
+
const top = await repoTop(repo);
|
|
222
|
+
if (!text.trim())
|
|
223
|
+
throw new CatherdError("E_INPUT_INVALID", "the knowledge line is empty", {
|
|
224
|
+
fix: 'pass one line of text, e.g. catherd knowledge add "the targeted test is bun test <file>"',
|
|
225
|
+
});
|
|
226
|
+
return appendKnowledge(top, now, BY_HAND, text);
|
|
194
227
|
}
|
|
@@ -5,7 +5,7 @@ import { CatherdError } from "../domain/errors.ts";
|
|
|
5
5
|
import { assertId } from "../domain/ids.ts";
|
|
6
6
|
import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
|
|
7
7
|
import type { OutcomeRow, RouteRow } from "../domain/route.ts";
|
|
8
|
-
import { slug } from "../domain/util.ts";
|
|
8
|
+
import { cell, slug } from "../domain/util.ts";
|
|
9
9
|
import { withFileLock } from "../infra/filelock.ts";
|
|
10
10
|
import { dataDir, repoDir, runsDir } from "../infra/paths.ts";
|
|
11
11
|
import {
|
|
@@ -273,6 +273,16 @@ export function appendLedger(run: Run, row: string): void {
|
|
|
273
273
|
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
|
|
274
274
|
export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Appends one line to the repo's knowledge.md, `- <date> <source>: <text>` with `text` on one line, and returns
|
|
278
|
+
* it. The single writer of knowledge.md: `land`'s `learned` and `catherd knowledge add` both come through here.
|
|
279
|
+
*/
|
|
280
|
+
export function appendKnowledge(toplevel: string, at: Date, source: string, text: string): string {
|
|
281
|
+
const line = `- ${at.toISOString().slice(0, 10)} ${source}: ${cell(text)}`;
|
|
282
|
+
appendPrivate(knowledgeFile(toplevel), `${line}\n`);
|
|
283
|
+
return line;
|
|
284
|
+
}
|
|
285
|
+
|
|
276
286
|
const SERVER_OWNED = new Set([
|
|
277
287
|
"meta.json",
|
|
278
288
|
"state.md",
|
|
@@ -85,14 +85,22 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
|
|
|
85
85
|
const { id, effort } = splitSourceRung(row.rung);
|
|
86
86
|
const family = map.family(id);
|
|
87
87
|
if (!family) (unmatched[source] ??= new Set()).add(id);
|
|
88
|
-
|
|
88
|
+
// spec 1.3 §7.1: a family with no effort (Composer) has one rung, #default, whatever effort a source names
|
|
89
|
+
const effortless = family !== null && familyEfforts(family).length === 0;
|
|
90
|
+
const e = effortless ? "default" : (effort ?? (family ? defaultEffortOf(ctx.sources, family) : ""));
|
|
89
91
|
const key = `${map.key(family?.id ?? id)}#${e}`;
|
|
90
92
|
const field = `${source}.${row.field}`;
|
|
91
93
|
const at = table.get(field) ?? new Map<string, Keyed>();
|
|
92
94
|
table.set(field, at);
|
|
93
95
|
const had = at.get(key);
|
|
94
96
|
if (!had || row.value > had.row.value)
|
|
95
|
-
at.set(key, {
|
|
97
|
+
at.set(key, {
|
|
98
|
+
key,
|
|
99
|
+
family,
|
|
100
|
+
effort: e,
|
|
101
|
+
assumed: effort === null && family !== null && !effortless,
|
|
102
|
+
row,
|
|
103
|
+
});
|
|
96
104
|
}
|
|
97
105
|
const valuesOf = (f: FieldRef) =>
|
|
98
106
|
new Map([...(table.get(`${f.source}.${f.field}`)?.values() ?? [])].map((k) => [k.key, k.row.value]));
|
|
@@ -163,7 +171,9 @@ export function derive(raw: RawAnswers, ctx: DeriveContext): Derived {
|
|
|
163
171
|
const family = map.family(id);
|
|
164
172
|
if (family && effort !== null) shipped.add(`${family.id}#${effort}|${s.dim}`);
|
|
165
173
|
}
|
|
166
|
-
scores.push(
|
|
174
|
+
scores.push(
|
|
175
|
+
...adjacent(ctx.models.families, scores, shipped, ctx.now, (f) => defaultEffortOf(ctx.sources, f)),
|
|
176
|
+
);
|
|
167
177
|
|
|
168
178
|
const { facts, warnings } = factsOf(raw, ctx, map);
|
|
169
179
|
return {
|
|
@@ -203,13 +213,16 @@ function aaFeatures(table: Map<string, Map<string, Keyed>>): Derived["features"]
|
|
|
203
213
|
* Spec 1.2 §4.3 `adjacent`: for each family effort a dimension has no value at (neither one in `direct` nor
|
|
204
214
|
* one `shipped` names, as `<family>#<effort>|<dim>`), the best value in `direct` at the nearest effort that
|
|
205
215
|
* has one (the weaker on a tie). A sync spreads only its own values, and never onto a value the shipped file
|
|
206
|
-
* carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`.
|
|
216
|
+
* carries; `rebuildShipped` spreads the shipped file's published values, with an empty `shipped`. A family that
|
|
217
|
+
* one backend runs without an effort (Cursor's bare slug, spec 1.3 §7.1) also gets `#default`, carried from the
|
|
218
|
+
* value nearest `defaultEffort(family)`.
|
|
207
219
|
*/
|
|
208
220
|
export function adjacent(
|
|
209
221
|
families: Family[],
|
|
210
222
|
direct: Score[],
|
|
211
223
|
shipped: ReadonlySet<string>,
|
|
212
224
|
now: number,
|
|
225
|
+
defaultEffort: (f: Family) => string = () => "high",
|
|
213
226
|
): Score[] {
|
|
214
227
|
const out: Score[] = [];
|
|
215
228
|
for (const f of families)
|
|
@@ -222,9 +235,11 @@ export function adjacent(
|
|
|
222
235
|
if (!had || outranks(s, had, now)) byEffort.set(effort, s);
|
|
223
236
|
}
|
|
224
237
|
if (byEffort.size === 0) continue;
|
|
225
|
-
|
|
238
|
+
const efforts = familyEfforts(f);
|
|
239
|
+
const bare = efforts.length > 0 && Object.values(f.on).some((o) => o && o.efforts.length === 0);
|
|
240
|
+
for (const e of bare ? [...efforts, "default"] : efforts) {
|
|
226
241
|
if (byEffort.has(e) || shipped.has(`${f.id}#${e}|${dim}`)) continue;
|
|
227
|
-
const near = nearestEffort(e, [...byEffort.keys()]);
|
|
242
|
+
const near = nearestEffort(e === "default" ? defaultEffort(f) : e, [...byEffort.keys()]);
|
|
228
243
|
const from = near ? byEffort.get(near) : undefined;
|
|
229
244
|
if (!near || !from) continue;
|
|
230
245
|
out.push({
|
|
@@ -261,7 +276,8 @@ function factsOf(
|
|
|
261
276
|
for (const f of ctx.models.families) {
|
|
262
277
|
const x: FamilyFacts = { on: {}, speed: {} };
|
|
263
278
|
const vendor = (f as { vendor?: unknown }).vendor;
|
|
264
|
-
const own =
|
|
279
|
+
const own =
|
|
280
|
+
md && typeof vendor === "string" ? modelsDevFacts(md, vendor, modelsDevId(md, vendor, f, map)) : null;
|
|
265
281
|
if (own?.price) x.price = own.price;
|
|
266
282
|
if (own && own.toolUse !== null && own.imageIn !== null && own.reasoning !== null)
|
|
267
283
|
x.capabilities = { toolUse: own.toolUse, imageIn: own.imageIn, reasoning: own.reasoning };
|
|
@@ -290,6 +306,15 @@ function factsOf(
|
|
|
290
306
|
return { facts, warnings };
|
|
291
307
|
}
|
|
292
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The id of `vendor`'s models.dev model that is `f`: its own id, else the first that maps to it (spec 1.3 §7.1:
|
|
311
|
+
* `gemini-3-8-flash` is Google's `gemini-3.8-flash`, `gemini-3-1-pro` its aliased `gemini-3.1-pro-preview`).
|
|
312
|
+
*/
|
|
313
|
+
function modelsDevId(md: unknown, vendor: string, f: Family, map: IdMapper): string {
|
|
314
|
+
const ids = Object.keys((md as Record<string, { models?: object }>)[vendor]?.models ?? {});
|
|
315
|
+
return ids.includes(f.id) ? f.id : (ids.find((id) => map.family(id) === f) ?? f.id);
|
|
316
|
+
}
|
|
317
|
+
|
|
293
318
|
/** The first entry of `list` whose id maps to `f` (OpenRouter's `:batch` variants never do). */
|
|
294
319
|
const entryFor = <T extends { id: string }>(list: T[], f: Family, map: IdMapper): T | undefined =>
|
|
295
320
|
list.find((m) => map.family(m.id) === f);
|