catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
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 +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
catalogPairs,
|
|
3
|
+
claudeBilled,
|
|
4
|
+
downgrade,
|
|
5
|
+
intelligenceOf,
|
|
6
|
+
quotaOf,
|
|
7
|
+
rankStandIns,
|
|
8
|
+
} from "../domain/failover.ts";
|
|
9
|
+
import { pairOf } from "../domain/catalog.ts";
|
|
10
|
+
import { tryParseRung } from "../domain/ids.ts";
|
|
11
|
+
import type { Role } from "../domain/roles.ts";
|
|
12
|
+
import { effortAllowed } from "../domain/select.ts";
|
|
13
|
+
import { standInFor } from "./backends.ts";
|
|
14
|
+
import { loadCatalog } from "./catalog-service.ts";
|
|
15
|
+
import type { Deps, ProfileView } from "./ports.ts";
|
|
16
|
+
import { listProfiles, readProfileDoc } from "./profile-store.ts";
|
|
17
|
+
import { runProfile } from "./run-pin.ts";
|
|
18
|
+
import type { Run } from "./run-store.ts";
|
|
19
|
+
|
|
20
|
+
/** The headless twin of a native Claude rung is what the daemon runs: one backend name for both. */
|
|
21
|
+
const backendOf = (rung: string): string | null => {
|
|
22
|
+
const b = tryParseRung(rung)?.backend ?? null;
|
|
23
|
+
return b === "claude" ? "claude-code" : b;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every backend the user configured (spec 2.0 decision 6): those of every rung in every saved profile, and of
|
|
28
|
+
* every stand-in a profile's `failover` names, not only the profiles a run uses.
|
|
29
|
+
*/
|
|
30
|
+
export function configuredBackends(): string[] {
|
|
31
|
+
const out = new Set<string>();
|
|
32
|
+
for (const name of listProfiles()) {
|
|
33
|
+
let doc: ReturnType<typeof readProfileDoc>;
|
|
34
|
+
try {
|
|
35
|
+
doc = readProfileDoc(name);
|
|
36
|
+
} catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const rungs = [
|
|
40
|
+
...Object.values(doc.roles ?? {}).flatMap((r) => r?.rungs ?? []),
|
|
41
|
+
...Object.values(doc.failover ?? {}),
|
|
42
|
+
];
|
|
43
|
+
for (const r of rungs) {
|
|
44
|
+
const b = backendOf(r);
|
|
45
|
+
if (b) out.add(b);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...out].sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The backends a limit may move work to: every configured one, and those of the run's own profile. */
|
|
52
|
+
export function failoverBackends(profile: ProfileView): Set<string> {
|
|
53
|
+
const backends = new Set(configuredBackends());
|
|
54
|
+
for (const r of [
|
|
55
|
+
...Object.values(profile.roles).flatMap((x) => x?.rungs ?? []),
|
|
56
|
+
...Object.values(profile.failover),
|
|
57
|
+
]) {
|
|
58
|
+
const b = backendOf(r);
|
|
59
|
+
if (b) backends.add(b);
|
|
60
|
+
}
|
|
61
|
+
return backends;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pairsOf = (c: ReturnType<typeof loadCatalog>): { rung: string; intelligence: number }[] =>
|
|
65
|
+
catalogPairs(c).map((rung) => ({
|
|
66
|
+
rung,
|
|
67
|
+
intelligence: (pairOf(c, rung) as { intelligence: number }).intelligence,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
/** Plan 29's index through one adapter, so plan 33 may rank on something else: every indexed rung and its index. */
|
|
71
|
+
export function indexedPairs(
|
|
72
|
+
_deps: Deps,
|
|
73
|
+
o: { repo?: string; runDir?: string } = {},
|
|
74
|
+
): { rung: string; intelligence: number }[] {
|
|
75
|
+
return pairsOf(loadCatalog(o));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where a unit goes when `rung`'s quota hit a limit (spec 2.0 decision 6): the profile's stand-in when its quota
|
|
80
|
+
* is free and it is not weaker; else the nearest indexed rung of any configured backend on a free quota that is
|
|
81
|
+
* not weaker, a Claude rung last. Never a step down: a rung with no index fails over only to its stand-in, and
|
|
82
|
+
* null means every candidate is held or weaker, so the caller waits for the hold. Plan 33: a stand-in above
|
|
83
|
+
* its model's `maxEffort`, or below medium for a coding worker, is never taken, whatever its quota or cost.
|
|
84
|
+
*/
|
|
85
|
+
export function failoverRung(deps: Deps, run: Run, role: Role, rung: string, held: string[]): string | null {
|
|
86
|
+
const from = tryParseRung(rung);
|
|
87
|
+
if (!from) return null;
|
|
88
|
+
const profile = runProfile(deps, run);
|
|
89
|
+
const c = loadCatalog({ repo: run.meta.repo, runDir: run.dir });
|
|
90
|
+
const free = (r: string): boolean => {
|
|
91
|
+
const p = tryParseRung(r);
|
|
92
|
+
return (
|
|
93
|
+
p !== null &&
|
|
94
|
+
quotaOf(p) !== quotaOf(from) &&
|
|
95
|
+
!held.includes(quotaOf(p)) &&
|
|
96
|
+
effortAllowed(profile.maxEffort ?? {}, role, r)
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
const stand = standInFor(profile.failover, rung, run.meta.repo);
|
|
100
|
+
const explicit = stand !== null && free(stand) && !downgrade(c, rung, stand) ? stand : null;
|
|
101
|
+
if (explicit && !claudeBilled(explicit)) return explicit;
|
|
102
|
+
if (intelligenceOf(c, rung) === null) return explicit;
|
|
103
|
+
const backends = failoverBackends(profile);
|
|
104
|
+
const pool = pairsOf(c)
|
|
105
|
+
.map((p) => p.rung)
|
|
106
|
+
.filter((r) => backends.has(backendOf(r) ?? ""));
|
|
107
|
+
const ranked = rankStandIns(c, profile.billing, rung, pool).filter(free);
|
|
108
|
+
return ranked.find((r) => !claudeBilled(r)) ?? explicit ?? ranked[0] ?? null;
|
|
109
|
+
}
|
package/src/services/finalize.ts
CHANGED
|
@@ -8,6 +8,11 @@ import { isCatherdError } from "../domain/errors.ts";
|
|
|
8
8
|
import { parseRung } from "../domain/ids.ts";
|
|
9
9
|
import {
|
|
10
10
|
type ExitInfo,
|
|
11
|
+
parseReplyDesign,
|
|
12
|
+
parseReplyEnvironment,
|
|
13
|
+
parseReplyExtras,
|
|
14
|
+
parseReplyHead,
|
|
15
|
+
parseReplyRejections,
|
|
11
16
|
parseReplyStatus,
|
|
12
17
|
type RunRecord,
|
|
13
18
|
THREAD_HEAVY_INPUT,
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
writeTextAtomic,
|
|
26
31
|
} from "../infra/store.ts";
|
|
27
32
|
import { type Dispatch, dispatchState, listDispatches, readProc } from "./dispatches.ts";
|
|
33
|
+
import { currentOwns } from "./lane-edit.ts";
|
|
28
34
|
import { tail } from "./run-debug.ts";
|
|
29
35
|
import { appendRecord, readRecords, recordsOnThread, type Run, runPaths } from "./run-store.ts";
|
|
30
36
|
|
|
@@ -75,7 +81,7 @@ function othersOwns(run: Run, self: Dispatch, start: number, end: number): strin
|
|
|
75
81
|
const to = ended.get(d.admit.dispatchId) ?? (Date.parse(readExit(d.dir)?.endedAt ?? "") || Date.now());
|
|
76
82
|
return from <= end && to >= start;
|
|
77
83
|
})
|
|
78
|
-
.flatMap((d) => d.admit.owns);
|
|
84
|
+
.flatMap((d) => [...d.admit.owns, ...(d.admit.ownsImplicit ?? [])]);
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/** The tree after the run, or null when git cannot say: the record is still written, its changes unknown. */
|
|
@@ -186,13 +192,24 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
186
192
|
const { changedOwned, violations } = after
|
|
187
193
|
? splitChanges(
|
|
188
194
|
changedPaths(a.before, after),
|
|
189
|
-
|
|
195
|
+
// spec 1.5 owns_add: paths the lane gained while it ran are its own, not violations; a laneless
|
|
196
|
+
// writer is held to its implicit docs lane (plan 21)
|
|
197
|
+
a.lane !== null
|
|
198
|
+
? [...new Set([...a.owns, ...currentOwns(run, a.lane)])]
|
|
199
|
+
: a.owns.length
|
|
200
|
+
? a.owns
|
|
201
|
+
: (a.ownsImplicit ?? []),
|
|
190
202
|
// from admission, when the before-snapshot was taken, not from the worker's start
|
|
191
203
|
othersOwns(run, d, Date.parse(a.admittedAt), end),
|
|
192
204
|
a.lane !== null || a.access === "read-only",
|
|
193
205
|
)
|
|
194
206
|
: { changedOwned: [], violations: [] };
|
|
195
207
|
const reported = parseReplyStatus(replyText);
|
|
208
|
+
const environment = parseReplyEnvironment(replyText);
|
|
209
|
+
const head = parseReplyHead(replyText);
|
|
210
|
+
const extras = parseReplyExtras(replyText);
|
|
211
|
+
const rejected = parseReplyRejections(replyText);
|
|
212
|
+
const design = parseReplyDesign(replyText);
|
|
196
213
|
return {
|
|
197
214
|
schema: 1,
|
|
198
215
|
runId: run.id,
|
|
@@ -219,6 +236,11 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
219
236
|
...(after ? {} : { gitUnavailable: true }),
|
|
220
237
|
replyStatus: reported.status,
|
|
221
238
|
replyWhy: reported.why,
|
|
239
|
+
...(environment ? { environment } : {}),
|
|
240
|
+
...(head ? { head } : {}),
|
|
241
|
+
...(extras.length ? { extras } : {}),
|
|
242
|
+
...(rejected.length ? { rejected } : {}),
|
|
243
|
+
...(design.length ? { design } : {}),
|
|
222
244
|
threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
|
|
223
245
|
access: a.access,
|
|
224
246
|
isolated: a.isolated,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { ROLE_ENV } from "../domain/role-scope.ts";
|
|
6
|
+
import { isCatherdSecret } from "../infra/env.ts";
|
|
7
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
8
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
9
|
+
import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
|
|
10
|
+
import { repoDir } from "../infra/paths.ts";
|
|
11
|
+
import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
12
|
+
import { knowledgeFor } from "./run-store.ts";
|
|
13
|
+
|
|
14
|
+
// Plan 23: the gate environment a repo's verifier and preflight need (DOCKER_HOST, HTTPS_PROXY, a
|
|
15
|
+
// TESTCONTAINERS_* setting), kept beside the repo's knowledge.md, so no verifier sets it up by hand. A value is
|
|
16
|
+
// stored as is; a secret is stored by reference only: the name of the env var that holds it, read when a
|
|
17
|
+
// role or a check starts, never written to disk.
|
|
18
|
+
|
|
19
|
+
const GATE_ENV_SCHEMA = 1;
|
|
20
|
+
|
|
21
|
+
const EntrySchema = z.union([z.strictObject({ value: z.string() }), z.strictObject({ from: z.string() })]);
|
|
22
|
+
export type GateEnvEntry = z.infer<typeof EntrySchema>;
|
|
23
|
+
|
|
24
|
+
const GateEnvSchema = z.looseObject({
|
|
25
|
+
schema: z.literal(GATE_ENV_SCHEMA),
|
|
26
|
+
vars: z.record(z.string(), EntrySchema),
|
|
27
|
+
});
|
|
28
|
+
type GateEnvFile = z.infer<typeof GateEnvSchema>;
|
|
29
|
+
|
|
30
|
+
/** `<data>/repos/<repo key>/gate-env.json`: where a toplevel keyed it (before 1.5, and with no origin). */
|
|
31
|
+
export const gateEnvFile = (toplevel: string): string => join(repoDir(toplevel), "gate-env.json");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Spec 1.5 "Knowledge keyed by git origin": the gate-env.json beside the repo's knowledge.md, which every
|
|
35
|
+
* worktree and clone of one repository shares. A toplevel-keyed file this worktree left before is migrated on
|
|
36
|
+
* read: its variables join the shared file (the shared file's own win), and it is renamed `.migrated`.
|
|
37
|
+
*/
|
|
38
|
+
export async function gateEnvFor(toplevel: string): Promise<string> {
|
|
39
|
+
const file = join(dirname(await knowledgeFor(toplevel)), "gate-env.json");
|
|
40
|
+
const legacy = gateEnvFile(toplevel);
|
|
41
|
+
if (file === legacy || !existsSync(legacy)) return file;
|
|
42
|
+
ensurePrivateDir(dirname(file));
|
|
43
|
+
await withFileLock(file, () => {
|
|
44
|
+
if (!existsSync(legacy)) return;
|
|
45
|
+
const vars = { ...readVarsAt(legacy), ...readVarsAt(file) };
|
|
46
|
+
writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
|
|
47
|
+
renameSync(legacy, `${legacy}.migrated`);
|
|
48
|
+
});
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readVarsAt(file: string): Record<string, GateEnvEntry> {
|
|
53
|
+
if (!existsSync(file)) return {};
|
|
54
|
+
return readVersioned<GateEnvFile>(file, GateEnvSchema, GATE_ENV_SCHEMA).vars;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
58
|
+
/** A name a secret goes by: such a variable is set by reference (--from), never by value. */
|
|
59
|
+
const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The names catherd sets in a dispatch itself: the role's identity, dispatch id, testcontainers session, scratch
|
|
63
|
+
* and working dir, the process basics, catherd's own dirs, and the homes an isolated CLI is moved to (codex,
|
|
64
|
+
* cursor, opencode, claude). A gate env entry under one would take the role's identity or isolation.
|
|
65
|
+
*/
|
|
66
|
+
const RESERVED = new Set([
|
|
67
|
+
ROLE_ENV,
|
|
68
|
+
DISPATCH_ID_ENV,
|
|
69
|
+
"TESTCONTAINERS_SESSION_ID",
|
|
70
|
+
"TMPDIR",
|
|
71
|
+
"PWD",
|
|
72
|
+
"PATH",
|
|
73
|
+
"HOME",
|
|
74
|
+
"CATHERD_HOME",
|
|
75
|
+
"CATHERD_CONFIG_DIR",
|
|
76
|
+
"CATHERD_DATA_DIR",
|
|
77
|
+
"CODEX_HOME",
|
|
78
|
+
"CLAUDE_CONFIG_DIR",
|
|
79
|
+
"CURSOR_CONFIG_DIR",
|
|
80
|
+
"CURSOR_DATA_DIR",
|
|
81
|
+
"XDG_CONFIG_HOME",
|
|
82
|
+
"XDG_DATA_HOME",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/** The repo's gate environment, by name; empty when none is set. */
|
|
86
|
+
export async function readGateEnv(toplevel: string): Promise<Record<string, GateEnvEntry>> {
|
|
87
|
+
return readVarsAt(await gateEnvFor(toplevel));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function top(repo: string): Promise<string> {
|
|
91
|
+
const t = await gitToplevel(repo);
|
|
92
|
+
if (!t)
|
|
93
|
+
throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
|
|
94
|
+
fix: "pass the path of the repository",
|
|
95
|
+
});
|
|
96
|
+
return t;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertName(name: string): void {
|
|
100
|
+
if (!NAME.test(name))
|
|
101
|
+
throw new CatherdError("E_INPUT_INVALID", `"${name}" is not an env var name`, {
|
|
102
|
+
fix: "use letters, digits and _, not starting with a digit, e.g. DOCKER_HOST",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function update(
|
|
107
|
+
repo: string,
|
|
108
|
+
change: (vars: Record<string, GateEnvEntry>) => void,
|
|
109
|
+
): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
|
|
110
|
+
const t = await top(repo);
|
|
111
|
+
const file = await gateEnvFor(t);
|
|
112
|
+
ensurePrivateDir(dirname(file));
|
|
113
|
+
return withFileLock(file, () => {
|
|
114
|
+
const vars = { ...readVarsAt(file) };
|
|
115
|
+
change(vars);
|
|
116
|
+
writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
|
|
117
|
+
return { repo: t, vars };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sets one variable of the repo's gate environment: a value, or `from`, the name of the env var that holds a
|
|
123
|
+
* secret. A secret-looking name (…KEY, …TOKEN, …PASSWORD) takes `from` only, so no secret lands on disk.
|
|
124
|
+
*/
|
|
125
|
+
export async function setGateEnv(
|
|
126
|
+
repo: string,
|
|
127
|
+
name: string,
|
|
128
|
+
entry: GateEnvEntry,
|
|
129
|
+
): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
|
|
130
|
+
assertName(name);
|
|
131
|
+
if (RESERVED.has(name))
|
|
132
|
+
throw new CatherdError("E_INPUT_INVALID", `${name} is set by catherd for every role`, {
|
|
133
|
+
fix: "the gate environment holds what a gate needs (DOCKER_HOST, a proxy, a TESTCONTAINERS_* setting), not a role's identity, scratch or home",
|
|
134
|
+
});
|
|
135
|
+
if ("from" in entry) {
|
|
136
|
+
assertName(entry.from);
|
|
137
|
+
if (isCatherdSecret(entry.from))
|
|
138
|
+
throw new CatherdError("E_INPUT_INVALID", `${entry.from} is catherd's own secret: no role gets it`, {
|
|
139
|
+
fix: "reference the env var that holds the gate's own secret",
|
|
140
|
+
});
|
|
141
|
+
} else if (SECRET_NAME.test(name))
|
|
142
|
+
throw new CatherdError(
|
|
143
|
+
"E_INPUT_INVALID",
|
|
144
|
+
`${name} looks like a secret: catherd stores it by reference only`,
|
|
145
|
+
{
|
|
146
|
+
fix: `export it in your shell, then: catherd knowledge env set ${name} --from <the env var that holds it>`,
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
return update(repo, (vars) => {
|
|
150
|
+
vars[name] = entry;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Removes one variable; refused when it is not set. */
|
|
155
|
+
export function removeGateEnv(
|
|
156
|
+
repo: string,
|
|
157
|
+
name: string,
|
|
158
|
+
): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
|
|
159
|
+
return update(repo, (vars) => {
|
|
160
|
+
if (!(name in vars))
|
|
161
|
+
throw new CatherdError("E_INPUT_INVALID", `${name} is not in the gate environment`, {
|
|
162
|
+
fix: "catherd knowledge env list shows what is set",
|
|
163
|
+
});
|
|
164
|
+
delete vars[name];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The variables as briefs show them: `NAME=value`, a reference as `NAME=$FROM`. */
|
|
169
|
+
export const gateEnvLines = (vars: Record<string, GateEnvEntry>): string[] =>
|
|
170
|
+
Object.entries(vars)
|
|
171
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
172
|
+
.map(([k, e]) => ("from" in e ? `${k}=$${e.from}` : `${k}=${e.value}`));
|
|
173
|
+
|
|
174
|
+
/** The literal values, for a spec written to disk; references stay names (`refs`), resolved at spawn. */
|
|
175
|
+
export function gateEnvParts(vars: Record<string, GateEnvEntry>): {
|
|
176
|
+
values: Record<string, string>;
|
|
177
|
+
refs: Record<string, string>;
|
|
178
|
+
} {
|
|
179
|
+
const values: Record<string, string> = {};
|
|
180
|
+
const refs: Record<string, string> = {};
|
|
181
|
+
for (const [k, e] of Object.entries(vars)) {
|
|
182
|
+
if ("from" in e) refs[k] = e.from;
|
|
183
|
+
else values[k] = e.value;
|
|
184
|
+
}
|
|
185
|
+
return { values, refs };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The env the gate environment adds to `base`: its values, and each reference read from `base`, else from
|
|
190
|
+
* `fallback` (the login env); a reference neither holds is left out, and named in `missing`.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveGateEnv(
|
|
193
|
+
vars: Record<string, GateEnvEntry>,
|
|
194
|
+
base: Record<string, string | undefined>,
|
|
195
|
+
fallback: () => Record<string, string | undefined> = () => ({}),
|
|
196
|
+
): { env: Record<string, string>; missing: string[] } {
|
|
197
|
+
const { values, refs } = gateEnvParts(vars);
|
|
198
|
+
return resolveRefs(values, refs, base, fallback);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `values` plus each of `refs` (name → the env var holding it) read from `base`, else `fallback`. */
|
|
202
|
+
export function resolveRefs(
|
|
203
|
+
values: Record<string, string>,
|
|
204
|
+
refs: Record<string, string>,
|
|
205
|
+
base: Record<string, string | undefined>,
|
|
206
|
+
fallback: () => Record<string, string | undefined> = () => ({}),
|
|
207
|
+
): { env: Record<string, string>; missing: string[] } {
|
|
208
|
+
const env = { ...values };
|
|
209
|
+
const missing: string[] = [];
|
|
210
|
+
let login: Record<string, string | undefined> | null = null;
|
|
211
|
+
for (const [k, from] of Object.entries(refs)) {
|
|
212
|
+
const v = base[from] ?? (login ??= fallback())[from];
|
|
213
|
+
if (v === undefined) missing.push(k);
|
|
214
|
+
else env[k] = v;
|
|
215
|
+
}
|
|
216
|
+
return { env, missing };
|
|
217
|
+
}
|