catherd-cli 1.1.0 → 1.2.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 (63) hide show
  1. package/README.md +40 -14
  2. package/catalog/ATTRIBUTION.md +36 -0
  3. package/catalog/models.json +10 -0
  4. package/catalog/scores.json +3006 -194
  5. package/catalog/sources.json +86 -0
  6. package/package.json +1 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +2 -2
  10. package/src/adapters/access.ts +57 -2
  11. package/src/domain/bars.ts +119 -0
  12. package/src/domain/calibration.ts +125 -0
  13. package/src/domain/catalog.ts +210 -23
  14. package/src/domain/failover.ts +14 -1
  15. package/src/domain/profile-rules.ts +108 -18
  16. package/src/domain/sources.ts +144 -0
  17. package/src/entry/catalog-command.ts +163 -8
  18. package/src/entry/init-command.ts +72 -2
  19. package/src/entry/mcp/lane-tools.ts +1 -1
  20. package/src/entry/mcp/server.ts +13 -3
  21. package/src/entry/mcp/setup-tools.ts +24 -2
  22. package/src/entry/profile-command.ts +10 -4
  23. package/src/entry/tui/commands.ts +20 -2
  24. package/src/entry/tui/effects.ts +50 -1
  25. package/src/entry/tui/fixtures.ts +31 -3
  26. package/src/entry/tui/profile-edits.ts +17 -9
  27. package/src/entry/tui/profile-tree.ts +2 -2
  28. package/src/entry/tui/run.tsx +11 -1
  29. package/src/entry/tui/state.ts +4 -0
  30. package/src/entry/tui/views/profile-actions.ts +9 -1
  31. package/src/entry/tui/views/profiles.tsx +81 -14
  32. package/src/entry/tui/views/save-dialog.tsx +12 -4
  33. package/src/infra/env.ts +2 -0
  34. package/src/infra/jev-client.ts +88 -40
  35. package/src/infra/paths.ts +2 -0
  36. package/src/infra/sources/arena.ts +87 -0
  37. package/src/infra/sources/artificial-analysis.ts +112 -0
  38. package/src/infra/sources/cache.ts +124 -0
  39. package/src/infra/sources/csv.ts +38 -0
  40. package/src/infra/sources/epoch.ts +82 -0
  41. package/src/infra/sources/http.ts +75 -0
  42. package/src/infra/sources/litellm.ts +58 -0
  43. package/src/infra/sources/models-dev.ts +59 -0
  44. package/src/infra/sources/openrouter-endpoints.ts +52 -0
  45. package/src/infra/sources/openrouter-models.ts +60 -0
  46. package/src/infra/sources/rows.ts +38 -0
  47. package/src/infra/sources/vectara.ts +69 -0
  48. package/src/infra/sources/zip.ts +48 -0
  49. package/src/services/catalog-refresh.ts +200 -0
  50. package/src/services/catalog-service.ts +83 -23
  51. package/src/services/credentials.ts +69 -0
  52. package/src/services/doctor-sources.ts +76 -0
  53. package/src/services/doctor.ts +10 -0
  54. package/src/services/jev-service.ts +14 -41
  55. package/src/services/ports.ts +6 -0
  56. package/src/services/profile-service.ts +8 -6
  57. package/src/services/provenance.ts +115 -0
  58. package/src/services/routing-service.ts +7 -2
  59. package/src/services/run-evidence.ts +86 -0
  60. package/src/services/source-derive.ts +349 -0
  61. package/src/services/source-sync.ts +305 -0
  62. package/src/services/standins.ts +204 -0
  63. package/src/services/treat-likes.ts +84 -0
@@ -2,7 +2,7 @@ import { realpathSync, statSync, watch } from "node:fs";
2
2
  import { adapterFor } from "../../adapters/registry.ts";
3
3
  import "../../adapters/all.ts";
4
4
  import { agentFiles } from "../../domain/agents.ts";
5
- import type { Catalog } from "../../domain/catalog.ts";
5
+ import { type Catalog, rungInfo } from "../../domain/catalog.ts";
6
6
  import { HARNESS_KEYS, type Profile, type ProfileDoc, type ProfilePatch } from "../../domain/profile.ts";
7
7
  import { type Validation, validateProfile } from "../../domain/profile-rules.ts";
8
8
  import type { Access } from "../../domain/record.ts";
@@ -17,6 +17,11 @@ import {
17
17
  } from "../../services/catalog-service.ts";
18
18
  import { cancel } from "../../services/dispatch-service.ts";
19
19
  import { type DoctorReport, doctor } from "../../services/doctor.ts";
20
+ import { ageText } from "../../services/doctor-sources.ts";
21
+ import { valuesUsed, type ValueUsed } from "../../services/provenance.ts";
22
+ import { evidenceLine, evidenceOf, runEvidence } from "../../services/run-evidence.ts";
23
+ import { type SyncReport, sourcesStatus, syncSources } from "../../services/source-sync.ts";
24
+ import { type Suggestion, suggestStandIns } from "../../services/standins.ts";
20
25
  import type { Synced } from "../../services/agent-links.ts";
21
26
  import {
22
27
  activate,
@@ -139,6 +144,46 @@ export interface Effects {
139
144
  create(name: string, from?: string): Saved;
140
145
  remove(name: string): Synced;
141
146
  refreshCatalog(): Promise<Refreshed[]>;
147
+ /** spec 1.2 §9 (`r`): syncs the public sources (each at most every 12 hours) */
148
+ syncSources(): Promise<SyncReport>;
149
+ /** spec 1.2 §9: each source's name, age and last error, as the `r` dialog shows them */
150
+ sources(): { source: string; name: string; age: string; error: string | null }[];
151
+ /** spec 1.2 §6.3: the three nearest stand-ins for a rung, for the treat-like picker */
152
+ suggest(c: Catalog, rung: string): Suggestion[];
153
+ /** spec 1.2 §5.3, §8: a rung's values with confidence and source, and catherd's own runs on it */
154
+ rungDetail(c: Catalog, rung: string): { values: ValueUsed[]; evidence: string | null };
155
+ }
156
+
157
+ /** Spec 1.2 §9: each source's age and last error, from the sync's state. */
158
+ export function sourceRows(now = Date.now()): ReturnType<Effects["sources"]> {
159
+ return sourcesStatus().sources.map((s) => ({
160
+ source: s.source,
161
+ name: s.name,
162
+ age: s.fetchedAt ? `${ageText(now - Date.parse(s.fetchedAt))} ago` : "never fetched",
163
+ error: s.error,
164
+ }));
165
+ }
166
+
167
+ /** Spec 1.2 §5.3, §8: what the rung detail shows; an unparseable rung has nothing to show. */
168
+ export function rungDetailOf(c: Catalog, rung: string): ReturnType<Effects["rungDetail"]> {
169
+ try {
170
+ const canonical = rungInfo(c, rung).canonical;
171
+ return {
172
+ values: valuesUsed(c, canonical),
173
+ evidence: evidenceLine(evidenceOf(runEvidence(c), canonical)),
174
+ };
175
+ } catch {
176
+ return { values: [], evidence: null };
177
+ }
178
+ }
179
+
180
+ /** Spec 1.2 §6.3: a rung's nearest stand-ins; none for a rung that does not parse. */
181
+ export function suggestFor(c: Catalog, rung: string): Suggestion[] {
182
+ try {
183
+ return suggestStandIns(c, rungInfo(c, rung).canonical);
184
+ } catch {
185
+ return [];
186
+ }
142
187
  }
143
188
 
144
189
  /** The word for `here`: the bound repo's profile, or the global active one. */
@@ -308,5 +353,9 @@ export function liveEffects(repo: string | null = null): Effects {
308
353
  create: createProfile,
309
354
  remove: deleteProfile,
310
355
  refreshCatalog: () => refreshDiscovery(),
356
+ syncSources: () => syncSources(),
357
+ sources: () => sourceRows(),
358
+ suggest: suggestFor,
359
+ rungDetail: rungDetailOf,
311
360
  };
312
361
  }
@@ -6,7 +6,7 @@ import {
6
6
  type ProfileDoc,
7
7
  resolveProfile,
8
8
  } from "../../domain/profile.ts";
9
- import { validateProfile } from "../../domain/profile-rules.ts";
9
+ import { repairs, validateProfile } from "../../domain/profile-rules.ts";
10
10
  import { catalogQuery, loadCatalog } from "../../services/catalog-service.ts";
11
11
  import type { RunRecord } from "../../domain/record.ts";
12
12
  import type { DoctorReport } from "../../services/doctor.ts";
@@ -17,8 +17,10 @@ import {
17
17
  type RoleDetail,
18
18
  type RoleRow,
19
19
  rowOf,
20
+ rungDetailOf,
20
21
  type SessionRow,
21
22
  type SessionRun,
23
+ suggestFor,
22
24
  } from "./effects.ts";
23
25
  import { withStaged } from "./profile-tree.ts";
24
26
 
@@ -401,6 +403,14 @@ export interface FixtureOptions {
401
403
  watchable?: boolean;
402
404
  }
403
405
 
406
+ /** Spec 1.2 §9: the sources as the `r` dialog shows them, one failing. */
407
+ export const FIXTURE_SOURCES: ReturnType<Effects["sources"]> = [
408
+ { source: "models-dev", name: "models.dev", age: "2 h ago", error: null },
409
+ { source: "arena", name: "Arena (LMArena)", age: "2 h ago", error: null },
410
+ { source: "epoch", name: "Epoch AI benchmarks", age: "1 d ago", error: "network error" },
411
+ { source: "artificial-analysis", name: "Artificial Analysis", age: "never fetched", error: null },
412
+ ];
413
+
404
414
  export function fixtureEffects(o: FixtureOptions = {}): Effects & {
405
415
  writes: string[];
406
416
  /** what a change to a watched run file does: every open watch hears of it */
@@ -425,9 +435,12 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
425
435
  staged: Record<string, string> = {},
426
436
  ) => {
427
437
  const p = resolveProfile(after, name);
428
- const v = validateProfile(p, withStaged(loadCatalog({ timings: false }), staged), BACKENDS);
438
+ const c = withStaged(loadCatalog({ timings: false }), staged);
439
+ const v = validateProfile(p, c, BACKENDS);
440
+ // spec 1.2 §6.2, as the ProfileService rules: a save that repairs part of an invalid profile goes through
441
+ const was = docs.has(name) ? validateProfile(resolveProfile(before, name), c, BACKENDS) : null;
429
442
  return {
430
- saved: v.errors.length === 0,
443
+ saved: repairs(was, v),
431
444
  ...v,
432
445
  diff: diffProfiles(resolveProfile(before, name), p),
433
446
  linked: [],
@@ -581,5 +594,20 @@ export function fixtureEffects(o: FixtureOptions = {}): Effects & {
581
594
  writes.push("refresh");
582
595
  return [{ backend: "codex", models: 14, fetchedAt: "2026-09-26T12:00:00.000Z" }];
583
596
  },
597
+ async syncSources() {
598
+ writes.push("sync");
599
+ return {
600
+ busy: false,
601
+ sources: [],
602
+ newlyScored: [],
603
+ noLongerNeeded: [],
604
+ failed: [],
605
+ warnings: [],
606
+ unmatched: {},
607
+ };
608
+ },
609
+ sources: () => FIXTURE_SOURCES,
610
+ suggest: suggestFor,
611
+ rungDetail: rungDetailOf,
584
612
  };
585
613
  }
@@ -1,3 +1,4 @@
1
+ import type { Suggestion } from "../../services/standins.ts";
1
2
  import { type Catalog, rungInfo, scoresOf } from "../../domain/catalog.ts";
2
3
  import { parseRung, tryParseRung } from "../../domain/ids.ts";
3
4
  import { NOTIFY, type Profile, type ProfilePatch } from "../../domain/profile.ts";
@@ -113,26 +114,33 @@ export function failoverOptions(
113
114
  if (!(r.enabled || usable(r.rung)) || r.rung.startsWith("claude:")) continue;
114
115
  const q = quotaOf(parseRung(r.rung));
115
116
  if (q === quota) continue;
116
- const via = inferredScores(c, rungInfo(c, r.rung)).via;
117
+ const note = inferredScores(c, rungInfo(c, r.rung)).note;
117
118
  out.push({
118
119
  value: r.rung,
119
120
  title: shortRung(r.rung),
120
121
  group: m.billing,
121
- detail: via ? `scores borrowed from ${via}` : "",
122
+ detail: note ?? "",
122
123
  current: r.rung === cur,
123
124
  });
124
125
  }
125
126
  return out;
126
127
  }
127
128
 
128
- /** The treat-like picker: every rung with scores of its own, by model. */
129
- export function treatLikeOptions(c: Catalog): SelectOption[] {
129
+ /**
130
+ * The treat-like picker: every rung with scores of its own, by model; spec 1.2 §6.4: the suggested stand-ins
131
+ * (`suggestions`, nearest first) say their distance and what they would lend.
132
+ */
133
+ export function treatLikeOptions(c: Catalog, suggestions: Suggestion[] = []): SelectOption[] {
130
134
  return Object.keys(c.scores)
131
135
  .filter((canonical) => scoresOf(c, canonical)?.via === null)
132
136
  .sort()
133
- .map((canonical) => ({
134
- value: canonical,
135
- title: canonical,
136
- group: canonical.slice(0, canonical.lastIndexOf("#")),
137
- }));
137
+ .map((canonical) => {
138
+ const s = suggestions.find((x) => x.like === canonical);
139
+ return {
140
+ value: canonical,
141
+ title: canonical,
142
+ group: canonical.slice(0, canonical.lastIndexOf("#")),
143
+ ...(s ? { detail: `distance ${s.distance.toFixed(2)} · lends ${s.lends.join(", ")}` } : {}),
144
+ };
145
+ });
138
146
  }
@@ -350,8 +350,8 @@ export function buildRows(i: TreeInput): Row[] {
350
350
  let mark = "";
351
351
  if (to) {
352
352
  try {
353
- const via = inferredScores(i.catalog, rungInfo(i.catalog, to)).via;
354
- mark = via ? ` (scores borrowed from ${via})` : "";
353
+ const note = inferredScores(i.catalog, rungInfo(i.catalog, to)).note;
354
+ mark = note ? ` (${note})` : "";
355
355
  } catch {
356
356
  mark = "";
357
357
  }
@@ -10,6 +10,16 @@ import { liveEffects } from "./effects.ts";
10
10
  import { fixtureEffects } from "./fixtures.ts";
11
11
  import { createAppKeymap } from "./providers/keymap.tsx";
12
12
  import { Providers } from "./providers/root.tsx";
13
+
14
+ /**
15
+ * OpenTUI's SystemClock counts from process start (performance.now), but the pages compare its time with the
16
+ * wall-clock timestamps catherd writes (admittedAt, createdAt, lastActivity), so it must be epoch milliseconds.
17
+ */
18
+ export class WallClock extends SystemClock {
19
+ override now(): number {
20
+ return Date.now();
21
+ }
22
+ }
13
23
  import { initialState, type Tab } from "./state.ts";
14
24
  import { detectUi, type ThemeMode } from "./theme.ts";
15
25
  import { App } from "./views/app.tsx";
@@ -72,7 +82,7 @@ export async function openTui(o: TuiOptions): Promise<number> {
72
82
  keybinds={keybinds}
73
83
  keymap={createAppKeymap(renderer)}
74
84
  effects={effects}
75
- clock={new SystemClock()}
85
+ clock={new WallClock()}
76
86
  initial={initialState(o.tab ?? "status")}
77
87
  copy={(text) => renderer.copyToClipboardOSC52(text)}
78
88
  onExit={(code, kept) => {
@@ -54,6 +54,10 @@ export type Purpose =
54
54
  | { type: "start"; role: Role }
55
55
  | { type: "treatLike"; rung: string; role: Role | null }
56
56
  | { type: "failover"; rung: string }
57
+ /** spec 1.2 §9: each source's age and last error, after `r` synced them */
58
+ | { type: "sources" }
59
+ /** spec 1.2 §9: a rung's values with confidence and source, and its runs */
60
+ | { type: "rung"; rung: string }
57
61
  | { type: "stories" };
58
62
 
59
63
  export interface SelectOption {
@@ -314,7 +314,15 @@ export function useProfileDialogs(): void {
314
314
  } catch (e) {
315
315
  fail(app, e);
316
316
  }
317
- app.toast({ variant: "success", message: `Saved profile ${p.name}` });
317
+ // spec 1.2 §6.2: a save that repaired part of an invalid profile says what is still open
318
+ app.toast(
319
+ r.errors.length
320
+ ? {
321
+ variant: "warning",
322
+ message: `Saved profile ${p.name}; ${r.errors.length} ${r.errors.length === 1 ? "error" : "errors"} still open`,
323
+ }
324
+ : { variant: "success", message: `Saved profile ${p.name}` },
325
+ );
318
326
  sessionsNeeded(app, r.newSessionNeededFor);
319
327
  if (value === "activate") activateNow(app, data, p.name, shown);
320
328
  });
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { type Profile, resolveProfile } from "../../../domain/profile.ts";
3
+ import type { Role } from "../../../domain/roles.ts";
3
4
  import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
4
5
  import { hereWord } from "../effects.ts";
5
6
  import { useApp, useDialogHandler } from "../providers/app.tsx";
@@ -215,17 +216,7 @@ export function ProfilesView(props: { width: number; height: number }) {
215
216
  empty: "no scored rung on another quota",
216
217
  },
217
218
  });
218
- else if (a.type === "rung" && !a.scored)
219
- app.dispatch({
220
- type: "open",
221
- dialog: {
222
- kind: "select",
223
- purpose: { type: "treatLike", rung: a.rung, role: a.role },
224
- title: `Treat ${a.rung} like…`,
225
- options: treatLikeOptions(catalog),
226
- empty: "no scored rung",
227
- },
228
- });
219
+ else if (a.type === "rung" && !a.scored) openTreatLike(a.rung, a.role);
229
220
  else if (a.type === "number") {
230
221
  const v = numberValue(profile, a.path);
231
222
  app.dispatch({
@@ -241,6 +232,49 @@ export function ProfilesView(props: { width: number; height: number }) {
241
232
  });
242
233
  }
243
234
  };
235
+ /** spec 1.2 §6.4: the treat-like picker, its three nearest stand-ins first */
236
+ const openTreatLike = (rung: string, role: Role | null) => {
237
+ if (!catalog) return;
238
+ const suggestions = app.effects.suggest(catalog, rung);
239
+ app.dispatch({
240
+ type: "open",
241
+ dialog: {
242
+ kind: "select",
243
+ purpose: { type: "treatLike", rung, role },
244
+ title: `Treat ${rung} like…`,
245
+ options: treatLikeOptions(catalog, suggestions),
246
+ suggested: suggestions.map((s) => s.like),
247
+ empty: "no scored rung",
248
+ },
249
+ });
250
+ };
251
+ /** spec 1.2 §9: the rung's values with confidence and source, and catherd's runs on it */
252
+ const openDetail = (rung: string) => {
253
+ if (!catalog) return;
254
+ const d = app.effects.rungDetail(catalog, rung);
255
+ app.dispatch({
256
+ type: "open",
257
+ dialog: {
258
+ kind: "select",
259
+ purpose: { type: "rung", rung },
260
+ title: rung,
261
+ options: [
262
+ ...d.values.map((v) => ({
263
+ value: v.dim,
264
+ title: `${v.dim} ${v.value}`,
265
+ group: "values",
266
+ detail: `${v.inferred ? `inferred from ${v.from}` : v.confidence} · ${v.source} ${v.benchmark} · ${v.date}`,
267
+ })),
268
+ {
269
+ value: "runs",
270
+ title: d.evidence ?? "no runs yet",
271
+ group: "catherd's runs (never used to route)",
272
+ },
273
+ ],
274
+ empty: "no values",
275
+ },
276
+ });
277
+ };
244
278
  const primary = (r: Row | null) => {
245
279
  const p = current();
246
280
  if (!r || !p) return;
@@ -267,10 +301,31 @@ export function ProfilesView(props: { width: number; height: number }) {
267
301
  "catalog.refresh": () => {
268
302
  // after a failed read, r reads it again (and only that)
269
303
  if (failure) return failure.retry();
270
- void app.effects.refreshCatalog().then(
271
- () => {
304
+ // spec 1.2 §9: r lists every backend's models and syncs the public sources, then shows each source's
305
+ // age and last error
306
+ void Promise.all([app.effects.refreshCatalog(), app.effects.syncSources()]).then(
307
+ ([, sync]) => {
272
308
  loaded.refresh();
273
- app.toast({ variant: "success", message: "Catalog refreshed" });
309
+ app.toast({
310
+ variant: sync.failed.length ? "warning" : "success",
311
+ message: sync.busy
312
+ ? "Catalog refreshed; another sync is running"
313
+ : `Catalog refreshed${sync.newlyScored.length ? `; ${sync.newlyScored.length} rungs newly scored` : ""}`,
314
+ });
315
+ app.dispatch({
316
+ type: "open",
317
+ dialog: {
318
+ kind: "select",
319
+ purpose: { type: "sources" },
320
+ title: "Sources",
321
+ options: app.effects.sources().map((s) => ({
322
+ value: s.source,
323
+ title: s.name,
324
+ detail: s.error ? `${s.age} · ${s.error}` : s.age,
325
+ })),
326
+ empty: "no sources",
327
+ },
328
+ });
274
329
  },
275
330
  (e: unknown) => app.toast(errorToast(e)),
276
331
  );
@@ -279,6 +334,16 @@ export function ProfilesView(props: { width: number; height: number }) {
279
334
  "edit.redo": () => app.dispatch({ type: "redo" }),
280
335
  });
281
336
  useCommandLayer("row.profiles", {
337
+ "tree.detail": () => {
338
+ const a = rowNow()?.action;
339
+ if (a?.type === "rung") openDetail(a.rung);
340
+ else app.toast({ variant: "info", message: "Pick a rung (an effort row) to see its values" });
341
+ },
342
+ "tree.treatLike": () => {
343
+ const a = rowNow()?.action;
344
+ if (a?.type === "rung") openTreatLike(a.rung, a.role);
345
+ else app.toast({ variant: "info", message: "Pick a rung (an effort row) to map it" });
346
+ },
282
347
  "tree.toggle": () => toggle(rowNow()),
283
348
  "tree.open": () => primary(rowNow()),
284
349
  "tree.expand": () => {
@@ -313,6 +378,8 @@ export function ProfilesView(props: { width: number; height: number }) {
313
378
  : null;
314
379
  app.dispatch({ type: "treatLike", rung: p.rung, like: value, ...(tick ? { patch: tick } : {}) });
315
380
  });
381
+ useDialogHandler("sources", () => app.dispatch({ type: "close" }));
382
+ useDialogHandler("rung", () => app.dispatch({ type: "close" }));
316
383
  useDialogHandler("number", (p, value) => {
317
384
  if (p.type !== "number") return;
318
385
  const r = parseNumber(p.path, value);
@@ -9,7 +9,7 @@ import {
9
9
  patchBetween,
10
10
  resolveProfile,
11
11
  } from "../../../domain/profile.ts";
12
- import type { Validation } from "../../../domain/profile-rules.ts";
12
+ import { repairs, type Validation } from "../../../domain/profile-rules.ts";
13
13
  import type { Effects } from "../effects.ts";
14
14
  import { useApp } from "../providers/app.tsx";
15
15
  import { useData, useLoad } from "../providers/data.tsx";
@@ -36,6 +36,8 @@ export interface SavePreview {
36
36
  changes: Change[];
37
37
  treatLikes: [string, string][];
38
38
  validation: Validation;
39
+ /** spec 1.2 §6.2: the save leaves errors but removes one of the stored profile's and adds none */
40
+ repair: boolean;
39
41
  agentsAdded: string[];
40
42
  agentsRemoved: string[];
41
43
  }
@@ -54,10 +56,13 @@ function previewSave(
54
56
  const { catalog } = fx.catalog(after.billing);
55
57
  const a = new Set(fx.agents(before));
56
58
  const b = new Set(fx.agents(after));
59
+ const staged = withStaged(catalog, d.treatLikes);
60
+ const validation = fx.validate(after, staged);
57
61
  return {
58
62
  changes: diffProfiles(before, after),
59
63
  treatLikes: Object.entries(d.treatLikes),
60
- validation: fx.validate(after, withStaged(catalog, d.treatLikes)),
64
+ validation,
65
+ repair: validation.errors.length > 0 && repairs(fx.validate(before, staged), validation),
61
66
  agentsAdded: [...b].filter((x) => !a.has(x)),
62
67
  agentsRemoved: [...a].filter((x) => !b.has(x)),
63
68
  };
@@ -200,7 +205,8 @@ export function SaveDialog(props: { dialog: Save }) {
200
205
  setChanged(true);
201
206
  return { same: false, now: doc };
202
207
  };
203
- const blocked = error !== null || (preview?.validation.errors.length ?? 0) > 0;
208
+ // spec 1.2 §6.2: a save that repairs part of an invalid profile is offered; any other error leaves Cancel
209
+ const blocked = error !== null || ((preview?.validation.errors.length ?? 0) > 0 && !preview?.repair);
204
210
  // spec §9.2's order: Save / Save & make active / Cancel; errors leave only Cancel
205
211
  const labels = blocked ? ["Cancel"] : ["Save", "Save & make active", "Cancel"];
206
212
  const [focused, setFocusedState] = useState(0);
@@ -272,7 +278,9 @@ export function SaveDialog(props: { dialog: Save }) {
272
278
  ],
273
279
  },
274
280
  {
275
- head: [[]],
281
+ head: preview.repair
282
+ ? [[], ...text("This save fixes an error and adds none; these stay open:", "warning")]
283
+ : [[]],
276
284
  hold: "error",
277
285
  more: (n) => `… and ${n} more ${n === 1 ? "error" : "errors"}`,
278
286
  items: preview.validation.errors.map((e) =>
package/src/infra/env.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
  const SECRET_ENV = new Set([
8
8
  "TYPESAFE_API_KEY",
9
+ // spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
10
+ "ARTIFICIAL_ANALYSIS_API_KEY",
9
11
  "CLAUDE_CODE_MESSAGING_SOCKET",
10
12
  "CLAUDE_CODE_MESSAGING_TOKEN",
11
13
  "CLAUDE_CODE_SESSION_ID",
@@ -1,5 +1,6 @@
1
1
  // Spec §5.5 transport: a thin client (the official SDK is 0.x and Node-targeted) with the SDK's retry
2
- // semantics (research 2026-09-25-jev.md §4, §5.6).
2
+ // semantics (research 2026-09-25-jev.md §4, §5.6). The public sources of spec 1.2 §3.3 reuse its policy
3
+ // through `retryingFetch`.
3
4
 
4
5
  export const JEV_BASE = "https://api.typesafe.ai/v1";
5
6
 
@@ -20,6 +21,19 @@ export type JevResponse =
20
21
  | { ok: true; body: unknown; requestId: string | null; latencyMs: number; attempts: number }
21
22
  | { ok: false; error: string; status: number | null; latencyMs: number; attempts: number };
22
23
 
24
+ /** What `retryingFetch` got: the body's bytes on a 2xx, else the last error and status. */
25
+ export type FetchOutcome =
26
+ | { ok: true; status: number; headers: Headers; bytes: Uint8Array; latencyMs: number; attempts: number }
27
+ | {
28
+ ok: false;
29
+ error: string;
30
+ status: number | null;
31
+ /** the last answer's headers, when there was an answer */
32
+ headers: Headers | null;
33
+ latencyMs: number;
34
+ attempts: number;
35
+ };
36
+
23
37
  const RETRY_AFTER_MAX_MS = 10_000;
24
38
  const retryable = (status: number) => status === 408 || status === 429 || status >= 500;
25
39
 
@@ -64,25 +78,30 @@ function httpError(status: number, text: string): string {
64
78
  }
65
79
 
66
80
  /**
67
- * `method path` against the Jev API with a bearer key: 10 s per attempt, up to two retries on 408, 429,
68
- * 5xx, a network error or a timeout, honouring Retry-After, and never past the 25 s deadline. The attempt
69
- * timer covers the whole exchange, body included, and aborts the request when it fires.
81
+ * `url` with the Jev client's retry policy: `attemptMs` per attempt (10 s), up to `retries` (two) retries on
82
+ * 408, 429, 5xx, a network error or a timeout, honouring Retry-After, and never past `deadlineMs` (25 s). The
83
+ * attempt timer covers the whole exchange, body included, and aborts the request when it fires. `headers`
84
+ * builds each attempt's headers from its number (1 first); `errorOf` names a failed status (`http <status>`).
85
+ * Bun's fetch honours HTTPS_PROXY and HTTP_PROXY.
70
86
  */
71
- export async function jevRequest(
72
- method: "GET" | "POST",
73
- path: string,
74
- key: string,
75
- body: unknown,
76
- o: JevTransport = {},
77
- ): Promise<JevResponse> {
87
+ export async function retryingFetch(
88
+ url: string,
89
+ init: { method: "GET" | "POST"; headers: (attempt: number) => Record<string, string>; body?: string },
90
+ o: JevTransport & { errorOf?: (status: number, text: string) => string } = {},
91
+ ): Promise<FetchOutcome> {
78
92
  const now = o.now ?? Date.now;
79
93
  const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
80
94
  const random = o.random ?? Math.random;
81
95
  const fetchImpl = o.fetchImpl ?? globalThis.fetch;
96
+ const errorOf = o.errorOf ?? ((status: number) => `http ${status}`);
82
97
  const start = now();
83
98
  const deadline = start + (o.deadlineMs ?? 25_000);
84
99
  const retries = o.retries ?? 2;
85
- let last: { error: string; status: number | null } = { error: "network error", status: null };
100
+ let last: { error: string; status: number | null; headers: Headers | null } = {
101
+ error: "network error",
102
+ status: null,
103
+ headers: null,
104
+ };
86
105
  let attempt = 0;
87
106
  for (;;) {
88
107
  attempt++;
@@ -100,52 +119,37 @@ export async function jevRequest(
100
119
  );
101
120
  });
102
121
  const exchange = async () => {
103
- const res = await fetchImpl(`${JEV_BASE}${path}`, {
104
- method,
105
- headers: {
106
- authorization: `Bearer ${key}`,
107
- ...(body === undefined ? {} : { "content-type": "application/json" }),
108
- ...(attempt > 1 ? { "x-typesafe-retry-count": String(attempt - 1) } : {}),
109
- },
110
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
122
+ const res = await fetchImpl(url, {
123
+ method: init.method,
124
+ headers: init.headers(attempt),
125
+ ...(init.body === undefined ? {} : { body: init.body }),
111
126
  signal: ctl.signal,
112
127
  });
113
- const text = res.ok ? await res.text() : await res.text().catch(() => "");
114
- return { res, text };
128
+ if (res.ok) return { res, bytes: new Uint8Array(await res.arrayBuffer()), text: "" };
129
+ return { res, bytes: new Uint8Array(), text: await res.text().catch(() => "") };
115
130
  };
116
131
  let wait: number | null = null;
117
132
  try {
118
133
  const r = await Promise.race([exchange(), timeout]);
119
134
  if (r === "timeout") {
120
- last = { error: "timeout", status: null };
135
+ last = { error: "timeout", status: null, headers: null };
121
136
  } else if (r.res.ok) {
122
- let parsed: unknown = null;
123
- try {
124
- parsed = JSON.parse(r.text);
125
- } catch {
126
- return {
127
- ok: false,
128
- error: "unexpected response",
129
- status: r.res.status,
130
- latencyMs: now() - start,
131
- attempts: attempt,
132
- };
133
- }
134
137
  return {
135
138
  ok: true,
136
- body: parsed,
137
- requestId: r.res.headers.get("x-typesafe-request-id"),
139
+ status: r.res.status,
140
+ headers: r.res.headers,
141
+ bytes: r.bytes,
138
142
  latencyMs: now() - start,
139
143
  attempts: attempt,
140
144
  };
141
145
  } else {
142
- last = { error: httpError(r.res.status, r.text), status: r.res.status };
146
+ last = { error: errorOf(r.res.status, r.text), status: r.res.status, headers: r.res.headers };
143
147
  if (!retryable(r.res.status))
144
148
  return { ok: false, ...last, latencyMs: now() - start, attempts: attempt };
145
149
  wait = retryAfterMs(r.res.headers, now());
146
150
  }
147
151
  } catch {
148
- last = { error: "network error", status: null };
152
+ last = { error: "network error", status: null, headers: null };
149
153
  } finally {
150
154
  clearTimeout(timer);
151
155
  ctl.abort();
@@ -153,10 +157,54 @@ export async function jevRequest(
153
157
  if (attempt > retries) break;
154
158
  const pause = wait ?? backoffMs(attempt, random);
155
159
  if (now() + pause >= deadline) {
156
- last = { error: `deadline (${last.error})`, status: last.status };
160
+ last = { ...last, error: `deadline (${last.error})` };
157
161
  break;
158
162
  }
159
163
  await sleep(pause);
160
164
  }
161
165
  return { ok: false, ...last, latencyMs: now() - start, attempts: attempt };
162
166
  }
167
+
168
+ /** `method path` against the Jev API with a bearer key, under `retryingFetch`'s policy. */
169
+ export async function jevRequest(
170
+ method: "GET" | "POST",
171
+ path: string,
172
+ key: string,
173
+ body: unknown,
174
+ o: JevTransport = {},
175
+ ): Promise<JevResponse> {
176
+ const r = await retryingFetch(
177
+ `${JEV_BASE}${path}`,
178
+ {
179
+ method,
180
+ headers: (attempt) => ({
181
+ authorization: `Bearer ${key}`,
182
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
183
+ ...(attempt > 1 ? { "x-typesafe-retry-count": String(attempt - 1) } : {}),
184
+ }),
185
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
186
+ },
187
+ { ...o, errorOf: httpError },
188
+ );
189
+ if (!r.ok)
190
+ return { ok: false, error: r.error, status: r.status, latencyMs: r.latencyMs, attempts: r.attempts };
191
+ let parsed: unknown = null;
192
+ try {
193
+ parsed = JSON.parse(new TextDecoder().decode(r.bytes));
194
+ } catch {
195
+ return {
196
+ ok: false,
197
+ error: "unexpected response",
198
+ status: r.status,
199
+ latencyMs: r.latencyMs,
200
+ attempts: r.attempts,
201
+ };
202
+ }
203
+ return {
204
+ ok: true,
205
+ body: parsed,
206
+ requestId: r.headers.get("x-typesafe-request-id"),
207
+ latencyMs: r.latencyMs,
208
+ attempts: r.attempts,
209
+ };
210
+ }
@@ -36,3 +36,5 @@ export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "ru
36
36
  export const logsDir = (): string => join(dataDir(), "logs");
37
37
  export const discoveryDir = (): string => join(dataDir(), "discovery");
38
38
  export const locksDir = (): string => join(dataDir(), "locks");
39
+ /** Spec 1.2 §3.3: each source's last good answer, the sync state and lock, and what the sync derived. */
40
+ export const sourcesDir = (): string => join(dataDir(), "sources");