catherd-cli 1.5.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 +164 -208
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -247
- 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 -421
- package/plugin/skills/catherd-setup/SKILL.md +20 -19
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +1 -2
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +4 -4
- package/src/cli.ts +22 -27
- 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 +21 -6
- package/src/domain/catalog.ts +69 -324
- package/src/domain/cost.ts +15 -30
- package/src/domain/errors.ts +2 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -87
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +1 -0
- 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 +22 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +25 -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 +101 -160
- package/src/domain/profile.ts +116 -63
- package/src/domain/record.ts +53 -2
- package/src/domain/role-prompts.ts +131 -45
- package/src/domain/role-scope.ts +4 -34
- package/src/domain/role-tools.ts +2 -2
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +17 -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 +151 -146
- package/src/domain/slopalytics.ts +72 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +25 -174
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +1 -1
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/init-command.ts +18 -68
- package/src/entry/knowledge-command.ts +45 -4
- package/src/entry/lock-command.ts +6 -7
- package/src/entry/mcp/role-server.ts +18 -0
- package/src/entry/mcp/server.ts +13 -40
- 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/profile-command.ts +10 -14
- package/src/entry/role-cli-command.ts +47 -3
- package/src/entry/runs-command.ts +3 -19
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/infra/env.ts +0 -2
- package/src/infra/git.ts +64 -0
- package/src/infra/heavy-lock.ts +45 -7
- package/src/infra/launch.ts +63 -7
- 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 +2 -2
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/services/admission.ts +24 -9
- package/src/services/backends.ts +4 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -262
- package/src/services/credentials.ts +4 -20
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +5 -1
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor.ts +4 -9
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +12 -0
- package/src/services/gate-env.ts +1 -2
- package/src/services/gate-service.ts +21 -6
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-service.ts +34 -3
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +35 -8
- package/src/services/notifier.ts +69 -2
- package/src/services/notify-service.ts +26 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +30 -4
- package/src/services/preflight.ts +82 -8
- package/src/services/profile-service.ts +30 -3
- package/src/services/profile-store.ts +8 -3
- package/src/services/protocol.ts +4 -0
- package/src/services/provenance.ts +31 -90
- package/src/services/questions.ts +9 -0
- package/src/services/reconcile.ts +11 -0
- package/src/services/routing-service.ts +26 -15
- 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-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +55 -6
- package/src/services/run-store.ts +54 -7
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/summary.ts +27 -3
- package/src/services/tree-path.ts +27 -0
- package/src/services/workspace-admission.ts +5 -13
- package/src/services/workspace-service.ts +5 -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 -44
- 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 -335
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -187
- package/src/adapters/grok/index.ts +0 -358
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -132
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -131
- package/src/entry/mcp/protocol-tools.ts +0 -63
- package/src/entry/mcp/push-tools.ts +0 -16
- package/src/entry/mcp/run-tools.ts +0 -164
- package/src/entry/mcp/setup-tools.ts +0 -109
- package/src/entry/mcp/workspace-tools.ts +0 -142
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -385
- 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 -400
- 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 -619
- package/src/entry/tui/views/save-dialog.tsx +0 -344
- package/src/entry/tui/views/status.tsx +0 -213
- 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 -95
- package/src/infra/sources/artificial-analysis.ts +0 -119
- package/src/infra/sources/cache.ts +0 -126
- 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 -290
- package/src/services/source-derive.ts +0 -397
- package/src/services/source-sync.ts +0 -312
- package/src/services/standins.ts +0 -238
- package/src/services/treat-likes.ts +0 -113
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
5
|
+
import { launchDaemon } from "../infra/launch.ts";
|
|
6
|
+
import type { SuperviseClock } from "../infra/supervisor.ts";
|
|
7
|
+
import { lockHeld, tryLock } from "../infra/filelock.ts";
|
|
8
|
+
import { log } from "../infra/log.ts";
|
|
9
|
+
import { processStartTime } from "../infra/proc.ts";
|
|
10
|
+
import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
11
|
+
import type { Deps } from "./ports.ts";
|
|
12
|
+
import type { RunEvent } from "../domain/run-events.ts";
|
|
13
|
+
import { type Action, actionKey, confirms, failKey, next } from "../domain/run-machine.ts";
|
|
14
|
+
import { reduce, replay, type RunState } from "../domain/run-state.ts";
|
|
15
|
+
import { type Effects, realEffects } from "./run-effects.ts";
|
|
16
|
+
import { appendEvent, eventsFile, isDaemonRun, readEvents } from "./run-events-store.ts";
|
|
17
|
+
import { findRun, type Run } from "./run-store.ts";
|
|
18
|
+
|
|
19
|
+
/** The daemon holds this lock for its whole life: a second holder gets none, a dead one's is reclaimed. */
|
|
20
|
+
export const daemonLockTarget = (run: Run): string => join(run.dir, "daemon");
|
|
21
|
+
export const heartbeatFile = (run: Run): string => join(run.dir, "daemon.json");
|
|
22
|
+
|
|
23
|
+
/** `beatMs`: how often the heartbeat is written; `pollMs`: how often the loop reads the events. Tests shorten them. */
|
|
24
|
+
export const daemonLimits = { beatMs: 5_000, pollMs: 500 };
|
|
25
|
+
|
|
26
|
+
export interface Heartbeat {
|
|
27
|
+
schema: 1;
|
|
28
|
+
pid: number;
|
|
29
|
+
startTime: string | null;
|
|
30
|
+
beatAt: string;
|
|
31
|
+
seq: number;
|
|
32
|
+
version: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HeartbeatSchema = z.looseObject({
|
|
36
|
+
schema: z.literal(1),
|
|
37
|
+
pid: z.number().int(),
|
|
38
|
+
startTime: z.string().nullable(),
|
|
39
|
+
beatAt: z.string(),
|
|
40
|
+
seq: z.number().int(),
|
|
41
|
+
version: z.string(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The daemon's last beat, or null when it never wrote one or the file cannot be read. */
|
|
45
|
+
export function readHeartbeat(run: Run): Heartbeat | null {
|
|
46
|
+
try {
|
|
47
|
+
return readVersioned(heartbeatFile(run), HeartbeatSchema, 1);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a live process holds the run's daemon lock: its pid and start time, never the heartbeat. */
|
|
54
|
+
export const daemonAlive = (run: Run): boolean => lockHeld(daemonLockTarget(run));
|
|
55
|
+
|
|
56
|
+
/** How the daemon is started; tests replace it so no process is spawned. */
|
|
57
|
+
export const daemonLauncher = { launch: launchDaemon };
|
|
58
|
+
|
|
59
|
+
/** Whether the run's events say it ended (`run.ended`). A `"type":"run.ended"` never occurs inside a JSON string. */
|
|
60
|
+
function hasEnded(run: Run): boolean {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(eventsFile(run), "utf8").includes('"type":"run.ended"');
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Makes sure a 2.0 run that has not ended has a daemon: one that is alive is left alone, a dead one is
|
|
70
|
+
* started again (two callers at once start two processes, and the lock lets one run). A 1.5 run has none.
|
|
71
|
+
*/
|
|
72
|
+
export function ensureDaemon(run: Run): "alive" | "revived" | "ended" | "1.5" {
|
|
73
|
+
if (!isDaemonRun(run)) return "1.5";
|
|
74
|
+
if (hasEnded(run)) return "ended";
|
|
75
|
+
if (daemonAlive(run)) return "alive";
|
|
76
|
+
daemonLauncher.launch(run.id, run.dir);
|
|
77
|
+
return "revived";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Writes the heartbeat: who holds the lock, since when, and how far it has read. */
|
|
81
|
+
export function beat(run: Run, deps: Deps, seq: number): void {
|
|
82
|
+
const pid = process.pid;
|
|
83
|
+
writeJsonAtomic(heartbeatFile(run), {
|
|
84
|
+
schema: 1,
|
|
85
|
+
pid,
|
|
86
|
+
startTime: processStartTime(pid),
|
|
87
|
+
beatAt: new Date(deps.now()).toISOString(),
|
|
88
|
+
seq,
|
|
89
|
+
version: deps.version,
|
|
90
|
+
} satisfies Heartbeat);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DaemonOptions {
|
|
94
|
+
/** what carries the machine's actions out; the real services when absent */
|
|
95
|
+
effects?: Effects;
|
|
96
|
+
/** the loop's time; the real clock when absent */
|
|
97
|
+
clock?: SuperviseClock;
|
|
98
|
+
/** ends the loop early (tests) */
|
|
99
|
+
until?: () => boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const REAL_CLOCK: SuperviseClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Drives one run: takes its lock (`"lost-lock"` without it), returns `"ended"` at once for a run that ended,
|
|
106
|
+
* else replays the events, announces itself, and then feeds each new event to the machine: it clears the keys
|
|
107
|
+
* the event confirms, runs each action of `next` it has not issued (not awaited), appends one `tick` when a
|
|
108
|
+
* hold or wait has passed and beats. It leaves, releasing the lock, once the run has ended and every effect
|
|
109
|
+
* is done. The effects append the events, so a crash loses nothing: a new daemon replays and issues again
|
|
110
|
+
* what never got its event.
|
|
111
|
+
*/
|
|
112
|
+
export async function runDaemon(
|
|
113
|
+
deps: Deps,
|
|
114
|
+
runId: string,
|
|
115
|
+
o: DaemonOptions = {},
|
|
116
|
+
): Promise<"ended" | "lost-lock"> {
|
|
117
|
+
const run = findRun(runId);
|
|
118
|
+
const release = tryLock(daemonLockTarget(run));
|
|
119
|
+
if (!release) return "lost-lock";
|
|
120
|
+
try {
|
|
121
|
+
if (!isDaemonRun(run) || hasEnded(run)) return "ended";
|
|
122
|
+
const clock = o.clock ?? REAL_CLOCK;
|
|
123
|
+
const effects = o.effects ?? realEffects(deps, run);
|
|
124
|
+
let state: RunState = replay(readEvents(run), run.id);
|
|
125
|
+
const hello = await appendEvent(
|
|
126
|
+
run,
|
|
127
|
+
{ type: "daemon.started", pid: process.pid, version: deps.version },
|
|
128
|
+
new Date(clock.now()),
|
|
129
|
+
);
|
|
130
|
+
log("info", "daemon", { run: run.id, pid: process.pid, seq: hello.seq });
|
|
131
|
+
beat(run, deps, hello.seq);
|
|
132
|
+
/** action key → the key `effect.failed` frees it by */
|
|
133
|
+
const issued = new Map<string, string>();
|
|
134
|
+
const inFlight = new Set<Promise<void>>();
|
|
135
|
+
const issue = (a: Action): void => {
|
|
136
|
+
const key = actionKey(a);
|
|
137
|
+
if (issued.has(key)) return;
|
|
138
|
+
issued.set(key, failKey(a, state));
|
|
139
|
+
const p: Promise<void> = effects
|
|
140
|
+
.run(a, state)
|
|
141
|
+
.catch((e: unknown) =>
|
|
142
|
+
log("error", "daemon", { run: run.id, action: a.type, error: errorMessage(e) }),
|
|
143
|
+
)
|
|
144
|
+
.finally(() => {
|
|
145
|
+
inFlight.delete(p);
|
|
146
|
+
// a notice, and a merge check that found nothing, leave no event behind: nothing else would free their key
|
|
147
|
+
if (a.type === "notify" || a.type === "checkMerged") issued.delete(key);
|
|
148
|
+
});
|
|
149
|
+
inFlight.add(p);
|
|
150
|
+
};
|
|
151
|
+
const take = (e: RunEvent): void => {
|
|
152
|
+
state = reduce(state, e);
|
|
153
|
+
for (const k of confirms(e))
|
|
154
|
+
for (const [key, fk] of issued) if (key === k || fk === k) issued.delete(key);
|
|
155
|
+
for (const a of next(state, e)) issue(a);
|
|
156
|
+
};
|
|
157
|
+
let beatAt = clock.now();
|
|
158
|
+
while (!o.until?.()) {
|
|
159
|
+
const fresh = readEvents(run, state.seq + 1);
|
|
160
|
+
for (const e of fresh) take(e);
|
|
161
|
+
if (fresh.length > 0) continue;
|
|
162
|
+
if (state.wakeAt !== null && Date.parse(state.wakeAt) <= clock.now()) {
|
|
163
|
+
await appendEvent(run, { type: "tick" }, new Date(clock.now()));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (clock.now() - beatAt >= daemonLimits.beatMs) {
|
|
167
|
+
beat(run, deps, state.seq);
|
|
168
|
+
beatAt = clock.now();
|
|
169
|
+
}
|
|
170
|
+
if (state.phase === "ended" && inFlight.size === 0) break;
|
|
171
|
+
await clock.sleep(daemonLimits.pollMs);
|
|
172
|
+
}
|
|
173
|
+
beat(run, deps, Math.max(state.seq, hello.seq));
|
|
174
|
+
return "ended";
|
|
175
|
+
} catch (e) {
|
|
176
|
+
log("error", "daemon", { run: runId, error: errorMessage(e) });
|
|
177
|
+
throw e;
|
|
178
|
+
} finally {
|
|
179
|
+
release();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from "./dispatches.ts";
|
|
36
36
|
import { finalizeDispatch, finalizingElsewhere, waitForFinish } from "./finalize.ts";
|
|
37
37
|
import type { Deps } from "./ports.ts";
|
|
38
|
+
import { isDaemonRun } from "./run-events-store.ts";
|
|
38
39
|
import { route } from "./lane-service.ts";
|
|
39
40
|
import { currentRoute } from "../domain/route.ts";
|
|
40
41
|
import { appendRoute, findRun, readRecords, readRoleRoutes, readRoutes, type Run } from "./run-store.ts";
|
|
@@ -223,6 +224,8 @@ function limitIsMine(deps: Deps, run: Run, origin: SessionRef | null): boolean {
|
|
|
223
224
|
* to their new owner (a second settle of a dispatch is harmless: finalize and failover run once).
|
|
224
225
|
*/
|
|
225
226
|
export function adopt(deps: Deps, run: Run): void {
|
|
227
|
+
// a 2.0 run's daemon watches its roles and fails them over itself
|
|
228
|
+
if (isDaemonRun(run)) return;
|
|
226
229
|
for (const d of liveDispatches(run, deps.now())) if (!watching.has(d.admit.dispatchId)) watch(deps, run, d);
|
|
227
230
|
}
|
|
228
231
|
|
|
@@ -252,6 +255,7 @@ export async function claim(deps: Deps, run: Run, o: { background?: boolean } =
|
|
|
252
255
|
* away has no live process to watch and no record to scan).
|
|
253
256
|
*/
|
|
254
257
|
export async function recoverOwned(deps: Deps, run: Run, o: { background?: boolean } = {}): Promise<void> {
|
|
258
|
+
if (isDaemonRun(run)) return;
|
|
255
259
|
adopt(deps, run);
|
|
256
260
|
let r = recovering.get(run.id);
|
|
257
261
|
if (!r) {
|
|
@@ -786,7 +790,7 @@ async function stopOrphan(deps: Deps, d: Dispatch): Promise<void> {
|
|
|
786
790
|
/**
|
|
787
791
|
* Spec §4.7: stop a live dispatch (interrupt, SIGTERM, SIGKILL) and record it as cancelled. With `read` (the MCP
|
|
788
792
|
* `cancel` tool, whose caller holds the record it returns) the record is marked read, so `peek` no longer lists
|
|
789
|
-
* it. Without it (the
|
|
793
|
+
* it. Without it (the panel, `catherd runs cancel`) the record stays unread and catherd announces it to the
|
|
790
794
|
* run's owner at `later` (spec §3.6).
|
|
791
795
|
*/
|
|
792
796
|
export async function cancel(
|
|
@@ -151,7 +151,7 @@ export async function backendChecks(
|
|
|
151
151
|
}
|
|
152
152
|
: { id: `backend:${id}`, label: id, state: "ok", word: "ready", detail },
|
|
153
153
|
);
|
|
154
|
-
// spec 1.3 §6.6: asked only of a logged-in CLI (a logged-out
|
|
154
|
+
// spec 1.3 §6.6: asked only of a logged-in CLI (a logged-out CLI may open a browser)
|
|
155
155
|
const quota = probe.loggedIn && a.quota ? await a.quota().catch(() => null) : null;
|
|
156
156
|
if (quota)
|
|
157
157
|
checks.push({ id: `quota:${id}`, label: `${id} quota`, state: "info", word: "quota", detail: quota });
|
|
@@ -7,6 +7,7 @@ import { claudeHome, locksDir } from "../infra/paths.ts";
|
|
|
7
7
|
import { ensurePrivateDir, PRIVATE_FILE } from "../infra/store.ts";
|
|
8
8
|
import { agentLinkState } from "./agent-links.ts";
|
|
9
9
|
import type { Handshake } from "./doctor.ts";
|
|
10
|
+
import { type IndexStatus, indexStatus } from "./catalog-service.ts";
|
|
10
11
|
import { activeName } from "./profile-store.ts";
|
|
11
12
|
|
|
12
13
|
// The rows of `catherd doctor` and the checks that stand alone; doctor.ts assembles the report.
|
|
@@ -40,6 +41,52 @@ export function guarded(id: string, label: string, fallbackFix: string, check: (
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
const HOUR = 3_600_000;
|
|
45
|
+
const DAY = 24 * HOUR;
|
|
46
|
+
|
|
47
|
+
/** An age as doctor prints it: minutes under an hour, hours under two days, else days. */
|
|
48
|
+
export function ageText(ms: number): string {
|
|
49
|
+
if (ms < HOUR) return `${Math.max(1, Math.round(ms / 60_000))} min`;
|
|
50
|
+
if (ms < 2 * DAY) return `${Math.round(ms / HOUR)} h`;
|
|
51
|
+
return `${Math.round(ms / DAY)} d`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `catalog` row: the Slopalytics index's age and the last fetch's error. `info` before the first fetch;
|
|
56
|
+
* a warning when the last fetch failed, or the index has not been checked for a day, since the hourly refresh
|
|
57
|
+
* would have. It reads the cache file only: no request.
|
|
58
|
+
*/
|
|
59
|
+
export function indexCheck(now = Date.now(), s: IndexStatus = indexStatus()): Check {
|
|
60
|
+
const base = { id: "catalog", label: "catalog" };
|
|
61
|
+
if (s.checkedAt === null || (s.fetchedAt === null && s.error === null))
|
|
62
|
+
return {
|
|
63
|
+
...base,
|
|
64
|
+
state: "info",
|
|
65
|
+
word: "not fetched",
|
|
66
|
+
detail: "no model is indexed yet",
|
|
67
|
+
fix: "catherd catalog sync",
|
|
68
|
+
};
|
|
69
|
+
const age = ageText(now - Date.parse(s.fetchedAt ?? s.checkedAt));
|
|
70
|
+
if (s.error !== null)
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
state: "warn",
|
|
74
|
+
word: "failing",
|
|
75
|
+
detail:
|
|
76
|
+
s.fetchedAt === null ? `${s.error}; no index` : `${s.error}; keeps the index fetched ${age} ago`,
|
|
77
|
+
fix: "catherd catalog sync --force",
|
|
78
|
+
};
|
|
79
|
+
if (now - Date.parse(s.checkedAt) > DAY)
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
state: "warn",
|
|
83
|
+
word: "stale",
|
|
84
|
+
detail: `${s.pairs} pairs, fetched ${age} ago`,
|
|
85
|
+
fix: "catherd catalog sync --force",
|
|
86
|
+
};
|
|
87
|
+
return { ...base, state: "ok", word: "fresh", detail: `${s.pairs} pairs, fetched ${age} ago` };
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
export function pluginCheck(version: string): Check {
|
|
44
91
|
const base = { id: "plugin", label: "Claude Code plugin" };
|
|
45
92
|
const file = join(claudeHome(), "plugins", "installed_plugins.json");
|
package/src/services/doctor.ts
CHANGED
|
@@ -12,12 +12,12 @@ import { linkedProfiles } from "./agent-links.ts";
|
|
|
12
12
|
import { accessChecks } from "./doctor-access.ts";
|
|
13
13
|
import { backendChecks, usedBackends } from "./doctor-backends.ts";
|
|
14
14
|
import { type PushProbe, pushCheck, probePush } from "./doctor-push.ts";
|
|
15
|
-
import { sourcesCheck, standInsToConfirmIn } from "./doctor-sources.ts";
|
|
16
15
|
import { cachesCheck, dockerChecks } from "./doctor-docker.ts";
|
|
17
16
|
import { roleMcpChecks } from "./doctor-role-mcp.ts";
|
|
18
17
|
import {
|
|
19
18
|
agentsCheck,
|
|
20
19
|
hostFlag,
|
|
20
|
+
indexCheck,
|
|
21
21
|
type Check,
|
|
22
22
|
errText,
|
|
23
23
|
fixOf,
|
|
@@ -176,7 +176,7 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
176
176
|
const named = (fix: string) =>
|
|
177
177
|
fix.replaceAll("catherd profile set ", `catherd profile set --profile ${name}${onHost} `);
|
|
178
178
|
const row = guarded(id, `profile ${name}`, validate, () => {
|
|
179
|
-
const v = validateNamed(name, d.repo, d.host.host);
|
|
179
|
+
const v = validateNamed(name, d.repo, d.host.host, { unindexed: "warning" });
|
|
180
180
|
const first = v.errors[0] ?? v.warnings[0];
|
|
181
181
|
return {
|
|
182
182
|
id,
|
|
@@ -242,14 +242,9 @@ export async function doctor(d: DoctorDeps): Promise<DoctorReport> {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
// spec 1.2 §9: the public sources' ages and errors, the Artificial Analysis key, and the active and linked
|
|
246
|
-
// profiles' stand-ins to confirm
|
|
247
245
|
const mine = [...(active ? [active] : []), ...diagnostics.filter((p) => p.name !== active?.name)];
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
sourcesCheck(Date.now(), standInsToConfirmIn(mine)),
|
|
251
|
-
),
|
|
252
|
-
);
|
|
246
|
+
// the Slopalytics index: its age and the last fetch's error
|
|
247
|
+
checks.push(guarded("catalog", "catalog", "catherd catalog sync", () => indexCheck()));
|
|
253
248
|
|
|
254
249
|
const native = used.get("claude");
|
|
255
250
|
const plugin = pluginCheck(d.version);
|
|
@@ -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,7 +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,
|
|
11
12
|
parseReplyEnvironment,
|
|
13
|
+
parseReplyExtras,
|
|
14
|
+
parseReplyHead,
|
|
15
|
+
parseReplyRejections,
|
|
12
16
|
parseReplyStatus,
|
|
13
17
|
type RunRecord,
|
|
14
18
|
THREAD_HEAVY_INPUT,
|
|
@@ -202,6 +206,10 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
202
206
|
: { changedOwned: [], violations: [] };
|
|
203
207
|
const reported = parseReplyStatus(replyText);
|
|
204
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);
|
|
205
213
|
return {
|
|
206
214
|
schema: 1,
|
|
207
215
|
runId: run.id,
|
|
@@ -229,6 +237,10 @@ async function compute(run: Run, d: Dispatch): Promise<RunRecord> {
|
|
|
229
237
|
replyStatus: reported.status,
|
|
230
238
|
replyWhy: reported.why,
|
|
231
239
|
...(environment ? { environment } : {}),
|
|
240
|
+
...(head ? { head } : {}),
|
|
241
|
+
...(extras.length ? { extras } : {}),
|
|
242
|
+
...(rejected.length ? { rejected } : {}),
|
|
243
|
+
...(design.length ? { design } : {}),
|
|
232
244
|
threadHeavy: o.tokens.input >= THREAD_HEAVY_INPUT,
|
|
233
245
|
access: a.access,
|
|
234
246
|
isolated: a.isolated,
|
package/src/services/gate-env.ts
CHANGED
|
@@ -61,7 +61,7 @@ const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
|
|
|
61
61
|
/**
|
|
62
62
|
* The names catherd sets in a dispatch itself: the role's identity, dispatch id, testcontainers session, scratch
|
|
63
63
|
* and working dir, the process basics, catherd's own dirs, and the homes an isolated CLI is moved to (codex,
|
|
64
|
-
*
|
|
64
|
+
* cursor, opencode, claude). A gate env entry under one would take the role's identity or isolation.
|
|
65
65
|
*/
|
|
66
66
|
const RESERVED = new Set([
|
|
67
67
|
ROLE_ENV,
|
|
@@ -75,7 +75,6 @@ const RESERVED = new Set([
|
|
|
75
75
|
"CATHERD_CONFIG_DIR",
|
|
76
76
|
"CATHERD_DATA_DIR",
|
|
77
77
|
"CODEX_HOME",
|
|
78
|
-
"GROK_HOME",
|
|
79
78
|
"CLAUDE_CONFIG_DIR",
|
|
80
79
|
"CURSOR_CONFIG_DIR",
|
|
81
80
|
"CURSOR_DATA_DIR",
|
|
@@ -9,6 +9,7 @@ import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
|
|
|
9
9
|
import { gateEnvLines, readGateEnv } from "./gate-env.ts";
|
|
10
10
|
import type { Deps } from "./ports.ts";
|
|
11
11
|
import { findRun, type Run } from "./run-store.ts";
|
|
12
|
+
import { treeOf } from "./tree-path.ts";
|
|
12
13
|
|
|
13
14
|
// Spec 1.1 §7: the verifier's gate ledger. A gate item that passed on the same content, with the same
|
|
14
15
|
// command, is carried over instead of run again; every check is a verifier step `peek` and `status` show.
|
|
@@ -316,8 +317,13 @@ const lastSteps = (steps: VerifierStep[]): VerifierStep[] =>
|
|
|
316
317
|
);
|
|
317
318
|
|
|
318
319
|
/** The pass gate_check carries for `command` on `paths` as the tree is now, if any. */
|
|
319
|
-
async function carryingPass(
|
|
320
|
-
|
|
320
|
+
async function carryingPass(
|
|
321
|
+
repo: string,
|
|
322
|
+
command: string,
|
|
323
|
+
paths: string[],
|
|
324
|
+
cwd: string = repo,
|
|
325
|
+
): Promise<GatePass | undefined> {
|
|
326
|
+
const { hash } = await contentHash(cwd, paths);
|
|
321
327
|
return readPasses(repo).findLast((p) => p.command === command && p.hash === hash);
|
|
322
328
|
}
|
|
323
329
|
|
|
@@ -340,7 +346,7 @@ export async function recordedItems(run: Run, m: string): Promise<RecordedItem[]
|
|
|
340
346
|
let passed = false;
|
|
341
347
|
if (command !== null && paths !== undefined) {
|
|
342
348
|
try {
|
|
343
|
-
passed = (await carryingPass(repo, command, paths)) !== undefined;
|
|
349
|
+
passed = (await carryingPass(repo, command, paths, treeOf(run, m))) !== undefined;
|
|
344
350
|
} catch (e) {
|
|
345
351
|
// a path gone from the tree carries no pass
|
|
346
352
|
if (!isCatherdError(e)) throw e;
|
|
@@ -379,7 +385,11 @@ export async function gateCheck(
|
|
|
379
385
|
> {
|
|
380
386
|
const run = findRun(i.run);
|
|
381
387
|
const paths = cleanPaths(i.paths);
|
|
382
|
-
|
|
388
|
+
if (run.meta.base && !i.milestone)
|
|
389
|
+
throw new CatherdError("E_INPUT_INVALID", "a base run's gate_check names its milestone", {
|
|
390
|
+
fix: "pass milestone",
|
|
391
|
+
});
|
|
392
|
+
const pass = await carryingPass(run.meta.repo, i.command, paths, treeOf(run, i.milestone ?? null));
|
|
383
393
|
recordStep(run, {
|
|
384
394
|
at: new Date(deps.now()).toISOString(),
|
|
385
395
|
item: i.item,
|
|
@@ -440,8 +450,13 @@ export async function gatePass(
|
|
|
440
450
|
): Promise<{ recorded: true; hash: string; commit: string }> {
|
|
441
451
|
const run = findRun(i.run);
|
|
442
452
|
const paths = cleanPaths(i.paths);
|
|
443
|
-
|
|
444
|
-
const
|
|
453
|
+
// a base run's milestone has its own tree: the one of the latest step that checked this item with this command
|
|
454
|
+
const step = readJsonl<VerifierStep>(stepsFile(run)).rows.findLast(
|
|
455
|
+
(s) => s?.item === i.item && s.command === i.command,
|
|
456
|
+
);
|
|
457
|
+
const cwd = treeOf(run, step?.milestone ?? null);
|
|
458
|
+
const { hash, dirty } = await contentHash(cwd, paths);
|
|
459
|
+
const head = (await gitHead(cwd)) ?? "none";
|
|
445
460
|
const commit = dirty ? `${head}+uncommitted` : head;
|
|
446
461
|
const file = gatesFile(run.meta.repo);
|
|
447
462
|
ensureJsonlHeader(file, "gates");
|
|
@@ -16,7 +16,7 @@ import { assetPath } from "../infra/assets.ts";
|
|
|
16
16
|
import { type JevTransport, jevRequest } from "../infra/jev-client.ts";
|
|
17
17
|
import { addSecret, log } from "../infra/log.ts";
|
|
18
18
|
import { appendJsonl, ensureJsonlHeader, readJsonl, readVersioned } from "../infra/store.ts";
|
|
19
|
-
import {
|
|
19
|
+
import { credentialsPath, saveCredential, savedCredential } from "./credentials.ts";
|
|
20
20
|
|
|
21
21
|
export { credentialsPath };
|
|
22
22
|
|
|
@@ -26,12 +26,10 @@ export const jevQuestions = (): JevFile =>
|
|
|
26
26
|
(file ??= readVersioned(assetPath("catalog/jev.json"), JevFileSchema, 1));
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Registers the saved
|
|
30
|
-
* logs without ever using them.
|
|
29
|
+
* Registers the saved key (Jev's) with the redactor, for a process that reads logs without ever using it.
|
|
31
30
|
*/
|
|
32
31
|
export function registerSavedSecrets(): void {
|
|
33
32
|
addSecret(jevKey());
|
|
34
|
-
addSecret(aaKey());
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
/** The key saved in credentials.json, or why that file cannot be read (unparsable, newer schema). */
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
milestoneCommits,
|
|
41
41
|
milestoneVerifier,
|
|
42
42
|
} from "./milestones.ts";
|
|
43
|
+
import { machineGate } from "./milestone-check.ts";
|
|
43
44
|
import type { Deps, Verdict } from "./ports.ts";
|
|
44
45
|
import {
|
|
45
46
|
appendKnowledge,
|
|
@@ -339,7 +340,7 @@ export type LandSkip = (typeof LAND_SKIPS)[number];
|
|
|
339
340
|
* Spec 1.1 §6: a milestone lands only with a reviewer record and a verifier verdict since its lanes
|
|
340
341
|
* started, or with a `skip` the commit range bears out. Throws E_LAND_GATE naming what is missing.
|
|
341
342
|
*/
|
|
342
|
-
async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefined): Promise<
|
|
343
|
+
async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefined): Promise<string[]> {
|
|
343
344
|
// spec 1.1 §8: a parked milestone waits on the owner, whatever else it has
|
|
344
345
|
const question = openQuestions(run).find((q) => q.milestone === m);
|
|
345
346
|
if (question)
|
|
@@ -374,7 +375,7 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
|
|
|
374
375
|
}
|
|
375
376
|
const against =
|
|
376
377
|
skip === "docs-only" ? files.filter((f) => !isDocPath(f)) : files.filter((f) => isSourcePath(f));
|
|
377
|
-
if (against.length === 0) return;
|
|
378
|
+
if (against.length === 0) return [];
|
|
378
379
|
const shown = `${against.slice(0, 5).join(", ")}${against.length > 5 ? `, and ${against.length - 5} more` : ""}`;
|
|
379
380
|
throw new CatherdError(
|
|
380
381
|
"E_LAND_GATE",
|
|
@@ -429,6 +430,35 @@ async function gate(run: Run, m: string, commit: string, skip: LandSkip | undefi
|
|
|
429
430
|
fix: `${marked}run reviewer-${m} (a dispatch, or a Claude subagent recorded with record_agent_run(name: "reviewer-${m}")) and the verifier on ${m}, recording it with record_agent_run(name: "verifier-${m}") (a FAIL with status "failed"), then land again; a docs-only milestone passes skip: "docs-only"`,
|
|
430
431
|
},
|
|
431
432
|
);
|
|
433
|
+
// plan 30b: the verdict must be about the commit being landed, and catherd's own check must have run on it
|
|
434
|
+
const landed = await fullCommit(run, commit);
|
|
435
|
+
const checked = verdict?.head ?? null;
|
|
436
|
+
if (verdict && checked === null)
|
|
437
|
+
throw new CatherdError(
|
|
438
|
+
"E_LAND_GATE",
|
|
439
|
+
`land ${m}: ${verdict.name}'s PASS names no HEAD, so it cannot be told to cover ${landed.slice(0, 12)}`,
|
|
440
|
+
{
|
|
441
|
+
fix: `a headless verifier's reply ends with HEAD: <sha>; run a fresh verifier-${m} on the committed milestone (${landed.slice(0, 7)})`,
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
if (verdict && checked !== null && !landed.startsWith(checked))
|
|
445
|
+
throw new CatherdError(
|
|
446
|
+
"E_LAND_GATE",
|
|
447
|
+
`land ${m}: ${verdict.name} checked ${checked.slice(0, 12)}, and the commit being landed is ${landed.slice(0, 12)}: a PASS from before a later commit does not count`,
|
|
448
|
+
{
|
|
449
|
+
fix: `run a fresh ${verdict.name} on ${landed.slice(0, 7)}: what passed is carried over by gate_check`,
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
const machine = await machineGate(run, m, landed);
|
|
453
|
+
if (machine.missing.length)
|
|
454
|
+
throw new CatherdError(
|
|
455
|
+
"E_LAND_GATE",
|
|
456
|
+
`land ${m}: the machine gate on ${landed.slice(0, 12)}: ${machine.missing.join("; ")}`,
|
|
457
|
+
{
|
|
458
|
+
fix: `run the full check with gate_run(run, "${m}", <command>) on ${landed.slice(0, 7)}; fix what fails, commit, and verify again`,
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
return machine.audit;
|
|
432
462
|
}
|
|
433
463
|
|
|
434
464
|
/**
|
|
@@ -508,7 +538,7 @@ async function landRun(
|
|
|
508
538
|
throw new CatherdError("E_RUN_COMMIT", `no commit ${i.commit} in ${run.meta.repo}`, {
|
|
509
539
|
fix: "commit the milestone first, then pass its hash",
|
|
510
540
|
});
|
|
511
|
-
await gate(run, i.milestone, i.commit, i.skip);
|
|
541
|
+
const audit = await gate(run, i.milestone, i.commit, i.skip);
|
|
512
542
|
// read before the ledger row: the range runs from the previous landing to this commit
|
|
513
543
|
const commits = await milestoneCommits(run, i.commit);
|
|
514
544
|
const now = new Date(deps.now());
|
|
@@ -530,6 +560,7 @@ async function landRun(
|
|
|
530
560
|
// the notes reach state.json first, with no git: the next landing counts its minutes from this one
|
|
531
561
|
await critical(() => patchNotes(run, landRow));
|
|
532
562
|
const { hints } = await refreshState(run);
|
|
563
|
+
hints.push(...audit);
|
|
533
564
|
// spec §5.6: every routed lane of the milestone lands with it
|
|
534
565
|
// under the routes lock, so a racing climb cannot slip between the read and the rows
|
|
535
566
|
let routed: string[] = [];
|