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.
Files changed (59) hide show
  1. package/README.md +74 -3
  2. package/catalog/models.json +148 -5
  3. package/catalog/scores.json +24 -0
  4. package/catalog/sources.json +12 -2
  5. package/package.json +3 -2
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/bin/catherd-mcp +1 -1
  8. package/plugin/skills/catherd/SKILL.md +3 -3
  9. package/plugin/skills/catherd-setup/SKILL.md +3 -3
  10. package/src/adapters/access.ts +54 -17
  11. package/src/adapters/all.ts +6 -0
  12. package/src/adapters/antigravity/events.ts +131 -0
  13. package/src/adapters/antigravity/home.ts +46 -0
  14. package/src/adapters/antigravity/index.ts +320 -0
  15. package/src/adapters/antigravity/models.ts +37 -0
  16. package/src/adapters/backend.ts +34 -1
  17. package/src/adapters/claude-code/index.ts +2 -0
  18. package/src/adapters/codex/index.ts +2 -0
  19. package/src/adapters/cursor/events.ts +120 -0
  20. package/src/adapters/cursor/home.ts +79 -0
  21. package/src/adapters/cursor/index.ts +331 -0
  22. package/src/adapters/cursor/models.ts +30 -0
  23. package/src/adapters/discovery.ts +21 -0
  24. package/src/adapters/grok/events.ts +112 -0
  25. package/src/adapters/grok/home.ts +132 -0
  26. package/src/adapters/grok/index.ts +339 -0
  27. package/src/adapters/grok/models.ts +48 -0
  28. package/src/adapters/opencode/index.ts +2 -0
  29. package/src/cli.ts +1 -0
  30. package/src/domain/catalog.ts +10 -1
  31. package/src/domain/cost.ts +1 -0
  32. package/src/domain/errors.ts +1 -0
  33. package/src/domain/failover.ts +5 -2
  34. package/src/domain/ids.ts +2 -2
  35. package/src/domain/profile.ts +57 -0
  36. package/src/domain/record.ts +2 -0
  37. package/src/domain/util.ts +3 -0
  38. package/src/entry/knowledge-command.ts +50 -0
  39. package/src/entry/tui/effects.ts +12 -3
  40. package/src/entry/tui/fixtures.ts +1 -0
  41. package/src/entry/tui/profile-tree.ts +10 -1
  42. package/src/entry/tui/views/profiles.tsx +14 -1
  43. package/src/infra/paths.ts +3 -1
  44. package/src/services/admission.ts +27 -4
  45. package/src/services/backends.ts +42 -4
  46. package/src/services/capture.ts +112 -54
  47. package/src/services/catalog-refresh.ts +2 -1
  48. package/src/services/dispatches.ts +2 -0
  49. package/src/services/doctor-access.ts +14 -7
  50. package/src/services/doctor-backends.ts +10 -1
  51. package/src/services/doctor-checks.ts +25 -0
  52. package/src/services/doctor.ts +3 -0
  53. package/src/services/finalize.ts +1 -0
  54. package/src/services/lane-service.ts +3 -10
  55. package/src/services/profile-service.ts +3 -3
  56. package/src/services/profile-store.ts +106 -2
  57. package/src/services/run-service.ts +37 -4
  58. package/src/services/run-store.ts +11 -1
  59. 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 { SourcesFile } from "../domain/sources.ts";
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 = "lock" | "temp" | "loopback" | "https" | "docker";
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
- /** The fix for a failed probe, per backend: Codex's sandbox holds catherd's grants; the others run unsandboxed. */
135
- function fixFor(backend: string, id: ProbeId): string {
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?.accessShell) continue;
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 = await a.accessShell({ network }).catch((e: unknown) => String(e));
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.probe().catch((e: unknown) => ({
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
+ }
@@ -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;
@@ -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, validateProfile } from "../domain/profile-rules.ts";
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
- runnableBackends,
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
- validateProfile(resolveProfile(doc, name), loadCatalog({ timings: false }), runnableBackends(), doc);
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 validateProfile(resolveProfile(doc, n), catalog, runnableBackends(), doc);
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
- /** What past runs of the repo learned; `repo` may be any path inside it. */
186
- export async function readKnowledge(repo: string): Promise<string> {
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
- const file = knowledgeFile(top);
193
- return existsSync(file) ? readFileSync(file, "utf8") : "catherd: no knowledge recorded yet for this repo";
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
- const e = effort ?? (family ? defaultEffortOf(ctx.sources, family) : "");
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, { key, family, effort: e, assumed: effort === null && family !== null, row });
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(...adjacent(ctx.models.families, scores, shipped, ctx.now));
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
- for (const e of familyEfforts(f)) {
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 = md && typeof vendor === "string" ? modelsDevFacts(md, vendor, f.id) : null;
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);