catherd-cli 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -25
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -69
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { assertId } from "../domain/ids.ts";
|
|
6
|
+
import { type RunRecord, RunRecordSchema } from "../domain/record.ts";
|
|
7
|
+
import type { OutcomeRow, RouteRow } from "../domain/route.ts";
|
|
8
|
+
import { slug } from "../domain/util.ts";
|
|
9
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
10
|
+
import { dataDir, repoDir, runsDir } from "../infra/paths.ts";
|
|
11
|
+
import {
|
|
12
|
+
appendJsonl,
|
|
13
|
+
ensureJsonlHeader,
|
|
14
|
+
ensurePrivateDir,
|
|
15
|
+
PRIVATE_DIR,
|
|
16
|
+
appendPrivate,
|
|
17
|
+
readJsonl,
|
|
18
|
+
readVersioned,
|
|
19
|
+
writeJsonAtomic,
|
|
20
|
+
writeTextAtomic,
|
|
21
|
+
} from "../infra/store.ts";
|
|
22
|
+
|
|
23
|
+
const RunMetaSchema = z.looseObject({
|
|
24
|
+
schema: z.literal(1),
|
|
25
|
+
id: z.string(),
|
|
26
|
+
repo: z.string(),
|
|
27
|
+
title: z.string(),
|
|
28
|
+
aLines: z.array(z.string()),
|
|
29
|
+
createdAt: z.string(),
|
|
30
|
+
catherdVersion: z.string(),
|
|
31
|
+
});
|
|
32
|
+
type RunMeta = z.infer<typeof RunMetaSchema>;
|
|
33
|
+
|
|
34
|
+
export interface Run {
|
|
35
|
+
id: string;
|
|
36
|
+
dir: string;
|
|
37
|
+
meta: RunMeta;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Spec §4.1. */
|
|
41
|
+
export function runPaths(dir: string) {
|
|
42
|
+
return {
|
|
43
|
+
meta: join(dir, "meta.json"),
|
|
44
|
+
plan: join(dir, "plan.md"),
|
|
45
|
+
lanes: join(dir, "lanes"),
|
|
46
|
+
ledger: join(dir, "ledger.md"),
|
|
47
|
+
state: join(dir, "state.md"),
|
|
48
|
+
stateJson: join(dir, "state.json"),
|
|
49
|
+
runs: join(dir, "runs.jsonl"),
|
|
50
|
+
routes: join(dir, "routes.jsonl"),
|
|
51
|
+
jev: join(dir, "jev.jsonl"),
|
|
52
|
+
outcomes: join(dir, "outcomes.jsonl"),
|
|
53
|
+
agents: join(dir, "agents.jsonl"),
|
|
54
|
+
harness: join(dir, "harness.jsonl"),
|
|
55
|
+
roles: join(dir, "roles"),
|
|
56
|
+
shots: join(dir, "shots"),
|
|
57
|
+
/** the admission lock's target: `admission.lock` guards dispatch admission */
|
|
58
|
+
admission: join(dir, "admission"),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const LEDGER_HEADER = "milestone | what | commit | minutes | evidence";
|
|
63
|
+
|
|
64
|
+
const runSlug = (title: string) => slug(title).slice(0, 40) || "run";
|
|
65
|
+
const stamp = (d: Date) => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
66
|
+
|
|
67
|
+
/** Creates a run folder under the repo's runs dir. `repo` is a git toplevel; meta.json is written last. */
|
|
68
|
+
export function createRun(o: {
|
|
69
|
+
repo: string;
|
|
70
|
+
title: string;
|
|
71
|
+
aLines: string[];
|
|
72
|
+
version: string;
|
|
73
|
+
now?: Date;
|
|
74
|
+
}): Run {
|
|
75
|
+
const now = o.now ?? new Date();
|
|
76
|
+
const root = runsDir(o.repo);
|
|
77
|
+
ensurePrivateDir(root);
|
|
78
|
+
const base = `${stamp(now)}-${runSlug(o.title)}`;
|
|
79
|
+
// findRun looks an id up across every repo, so an id is claimed across every repo too: the claim
|
|
80
|
+
// folder is atomic between processes, the scan covers runs made before claims existed
|
|
81
|
+
const claims = join(dataDir(), "run-ids");
|
|
82
|
+
ensurePrivateDir(claims);
|
|
83
|
+
const repos = join(dataDir(), "repos");
|
|
84
|
+
const taken = (id: string) =>
|
|
85
|
+
existsSync(repos) && readdirSync(repos).some((r) => existsSync(join(repos, r, "runs", id)));
|
|
86
|
+
let id = base;
|
|
87
|
+
for (let n = 2; ; n++) {
|
|
88
|
+
try {
|
|
89
|
+
if (!taken(id)) {
|
|
90
|
+
mkdirSync(join(claims, id), { mode: PRIVATE_DIR });
|
|
91
|
+
mkdirSync(join(root, id), { mode: PRIVATE_DIR });
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
96
|
+
}
|
|
97
|
+
id = `${base}-${n}`;
|
|
98
|
+
}
|
|
99
|
+
const dir = join(root, id);
|
|
100
|
+
const p = runPaths(dir);
|
|
101
|
+
for (const d of [p.lanes, p.roles, p.shots]) ensurePrivateDir(d);
|
|
102
|
+
writeTextAtomic(p.ledger, `${LEDGER_HEADER}\n`);
|
|
103
|
+
ensureJsonlHeader(p.runs, "runs");
|
|
104
|
+
ensureJsonlHeader(p.routes, "routes");
|
|
105
|
+
ensureJsonlHeader(p.agents, "agents");
|
|
106
|
+
const meta: RunMeta = {
|
|
107
|
+
schema: 1,
|
|
108
|
+
id,
|
|
109
|
+
repo: o.repo,
|
|
110
|
+
title: o.title,
|
|
111
|
+
aLines: o.aLines,
|
|
112
|
+
createdAt: now.toISOString(),
|
|
113
|
+
catherdVersion: o.version,
|
|
114
|
+
};
|
|
115
|
+
writeJsonAtomic(p.meta, meta);
|
|
116
|
+
return { id, dir, meta };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface RunListing {
|
|
120
|
+
runs: Run[];
|
|
121
|
+
/** run folders whose meta.json cannot be read: listed as warnings, never fatal (audit C10) */
|
|
122
|
+
corrupt: { id: string; dir: string; reason: string }[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every run of every repo, newest first. */
|
|
126
|
+
export function listRuns(): RunListing {
|
|
127
|
+
const root = join(dataDir(), "repos");
|
|
128
|
+
const out: RunListing = { runs: [], corrupt: [] };
|
|
129
|
+
if (!existsSync(root)) return out;
|
|
130
|
+
for (const repo of readdirSync(root, { withFileTypes: true })) {
|
|
131
|
+
const runs = join(root, repo.name, "runs");
|
|
132
|
+
if (!repo.isDirectory() || !existsSync(runs)) continue;
|
|
133
|
+
for (const e of readdirSync(runs, { withFileTypes: true })) {
|
|
134
|
+
if (!e.isDirectory()) continue;
|
|
135
|
+
const dir = join(runs, e.name);
|
|
136
|
+
try {
|
|
137
|
+
out.runs.push({ id: e.name, dir, meta: readVersioned(runPaths(dir).meta, RunMetaSchema, 1) });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
out.corrupt.push({ id: e.name, dir, reason: (err as Error).message.split("\n")[0] ?? "unreadable" });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
out.runs.sort((a, b) => b.meta.createdAt.localeCompare(a.meta.createdAt));
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every run's records on a backend's thread, oldest first: a role may resume a thread an earlier catherd
|
|
149
|
+
* run started. `run` counts even when a listing would miss it.
|
|
150
|
+
*/
|
|
151
|
+
export function recordsOnThread(run: Run, backend: string, thread: string): RunRecord[] {
|
|
152
|
+
const runs = listRuns().runs;
|
|
153
|
+
return (runs.some((r) => r.dir === run.dir) ? runs : [run, ...runs])
|
|
154
|
+
.flatMap((r) => readRecords(r).records)
|
|
155
|
+
.filter((r) => r.backend === backend && r.thread === thread)
|
|
156
|
+
.sort((a, b) => a.endedAt.localeCompare(b.endedAt));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The fix of an unknown run id, in the MCP tools' words; the CLI prints its own (entry/cli-kit.ts). */
|
|
160
|
+
export const RUN_NOT_FOUND_FIX = "status() lists the runs";
|
|
161
|
+
|
|
162
|
+
export function findRun(id: string): Run {
|
|
163
|
+
assertId("run", id);
|
|
164
|
+
const { runs, corrupt } = listRuns();
|
|
165
|
+
const run = runs.find((r) => r.id === id);
|
|
166
|
+
if (run) return run;
|
|
167
|
+
const bad = corrupt.find((r) => r.id === id);
|
|
168
|
+
if (bad)
|
|
169
|
+
throw new CatherdError("E_RUN_CORRUPT", `run ${id} cannot be read: ${bad.reason}`, {
|
|
170
|
+
fix: `fix or delete ${bad.dir}`,
|
|
171
|
+
});
|
|
172
|
+
throw new CatherdError("E_RUN_NOT_FOUND", `no run "${id}"`, { fix: RUN_NOT_FOUND_FIX });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The run's records, one per dispatch; rows that are torn, invalid or repeated are skipped and counted. */
|
|
176
|
+
export function readRecords(run: Run): { records: RunRecord[]; corrupt: number } {
|
|
177
|
+
const { rows, corrupt } = readJsonl<unknown>(runPaths(run.dir).runs);
|
|
178
|
+
const seen = new Set<string>();
|
|
179
|
+
const records: RunRecord[] = [];
|
|
180
|
+
let bad = corrupt;
|
|
181
|
+
for (const row of rows) {
|
|
182
|
+
const r = RunRecordSchema.safeParse(row);
|
|
183
|
+
if (!r.success) bad++;
|
|
184
|
+
else if (!seen.has(r.data.dispatchId)) {
|
|
185
|
+
seen.add(r.data.dispatchId);
|
|
186
|
+
records.push(r.data);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { records, corrupt: bad };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Appends `r` unless its dispatch already has a record, and returns the record that stands (audit C2). */
|
|
193
|
+
export function appendRecord(run: Run, r: RunRecord): Promise<RunRecord> {
|
|
194
|
+
const file = runPaths(run.dir).runs;
|
|
195
|
+
return withFileLock(file, () => {
|
|
196
|
+
const existing = readRecords(run).records.find((x) => x.dispatchId === r.dispatchId);
|
|
197
|
+
if (existing) return existing;
|
|
198
|
+
ensureJsonlHeader(file, "runs");
|
|
199
|
+
appendJsonl(file, RunRecordSchema.parse(r));
|
|
200
|
+
return r;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function readRoutes(run: Run): RouteRow[] {
|
|
205
|
+
return readJsonl<RouteRow>(runPaths(run.dir).routes).rows.filter(
|
|
206
|
+
(r) => typeof r?.lane === "string" && Array.isArray(r.ladder) && typeof r.rung === "string",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function appendRoute(run: Run, row: RouteRow): void {
|
|
211
|
+
const file = runPaths(run.dir).routes;
|
|
212
|
+
ensureJsonlHeader(file, "routes");
|
|
213
|
+
appendJsonl(file, row);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Appends; a lane may be written more than once and its last row wins (see `latestOutcomes`). */
|
|
217
|
+
export function appendOutcome(run: Run, row: OutcomeRow): void {
|
|
218
|
+
const file = runPaths(run.dir).outcomes;
|
|
219
|
+
ensureJsonlHeader(file, "outcomes");
|
|
220
|
+
appendJsonl(file, row);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readOutcomes(run: Run): OutcomeRow[] {
|
|
224
|
+
return readJsonl<OutcomeRow>(runPaths(run.dir).outcomes).rows.filter((r) => typeof r?.lane === "string");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const AgentRunSchema = z.looseObject({
|
|
228
|
+
at: z.string(),
|
|
229
|
+
name: z.string(),
|
|
230
|
+
role: z.string(),
|
|
231
|
+
rung: z.string(),
|
|
232
|
+
agent: z.string().nullable(),
|
|
233
|
+
totalTokens: z.number().nonnegative(),
|
|
234
|
+
costUsd: z.number().nullable(),
|
|
235
|
+
secs: z.number().nullable(),
|
|
236
|
+
status: z.enum(["ok", "failed", "cancelled"]),
|
|
237
|
+
/** the lane the subagent worked, so catalog timings count it under that lane's kind; rows before it lack it */
|
|
238
|
+
lane: z.string().nullable().optional(),
|
|
239
|
+
});
|
|
240
|
+
export type AgentRun = z.infer<typeof AgentRunSchema>;
|
|
241
|
+
|
|
242
|
+
/** Native Claude subagent runs, as the orchestrator reported them through record_agent_run. */
|
|
243
|
+
export function readAgentRuns(run: Run): AgentRun[] {
|
|
244
|
+
return readJsonl<unknown>(runPaths(run.dir).agents).rows.flatMap((row) => {
|
|
245
|
+
const r = AgentRunSchema.safeParse(row);
|
|
246
|
+
return r.success ? [r.data] : [];
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function appendAgentRun(run: Run, a: AgentRun): void {
|
|
251
|
+
const file = runPaths(run.dir).agents;
|
|
252
|
+
ensureJsonlHeader(file, "agents");
|
|
253
|
+
appendJsonl(file, AgentRunSchema.parse(a));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function appendLedger(run: Run, row: string): void {
|
|
257
|
+
appendPrivate(runPaths(run.dir).ledger, `${row}\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Spec §4.7: what past runs of a repo learned, keyed by its git toplevel. */
|
|
261
|
+
export const knowledgeFile = (toplevel: string): string => join(repoDir(toplevel), "knowledge.md");
|
|
262
|
+
|
|
263
|
+
const SERVER_OWNED = new Set([
|
|
264
|
+
"meta.json",
|
|
265
|
+
"state.md",
|
|
266
|
+
"state.json",
|
|
267
|
+
"ledger.md",
|
|
268
|
+
"runs.jsonl",
|
|
269
|
+
"routes.jsonl",
|
|
270
|
+
"jev.jsonl",
|
|
271
|
+
"agents.jsonl",
|
|
272
|
+
"harness.jsonl",
|
|
273
|
+
"outcomes.jsonl",
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const present = (p: string) => {
|
|
277
|
+
try {
|
|
278
|
+
lstatSync(p);
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The absolute path of `path` inside the run folder. Refuses a path, or a symlink on its way, that
|
|
287
|
+
* leaves the folder; for writes, also catherd's own files, compared case-folded (audit S7).
|
|
288
|
+
*/
|
|
289
|
+
export function runFile(run: Run, path: string, mode: "read" | "write"): string {
|
|
290
|
+
const full = resolve(run.dir, path);
|
|
291
|
+
const rel = relative(run.dir, full);
|
|
292
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(run.dir, rel) !== full)
|
|
293
|
+
throw new CatherdError("E_IO_PATH", `"${path}" is outside the run folder`, {
|
|
294
|
+
fix: "pass a path relative to the run folder, like lanes/M1.L1.md",
|
|
295
|
+
});
|
|
296
|
+
const folded = rel.toLowerCase();
|
|
297
|
+
if (
|
|
298
|
+
mode === "write" &&
|
|
299
|
+
(SERVER_OWNED.has(folded) ||
|
|
300
|
+
folded === "roles" ||
|
|
301
|
+
folded.startsWith(`roles${sep}`) ||
|
|
302
|
+
folded.endsWith(".lock"))
|
|
303
|
+
)
|
|
304
|
+
throw new CatherdError("E_IO_PATH", `${rel} is written by catherd itself`, {
|
|
305
|
+
fix: "write plan.md, lanes/<id>.md, a dossier or notes instead",
|
|
306
|
+
});
|
|
307
|
+
let probe = full;
|
|
308
|
+
while (!present(probe)) probe = dirname(probe);
|
|
309
|
+
const root = realpathSync(run.dir);
|
|
310
|
+
const real = realpathSync(probe);
|
|
311
|
+
if (real !== root && !real.startsWith(`${root}${sep}`))
|
|
312
|
+
throw new CatherdError("E_IO_PATH", `"${path}" resolves outside the run folder`, {
|
|
313
|
+
fix: "remove the symlink that leaves the run folder",
|
|
314
|
+
});
|
|
315
|
+
return full;
|
|
316
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type { Issue } from "../domain/profile-rules.ts";
|
|
4
|
+
import { configDir } from "../infra/paths.ts";
|
|
5
|
+
import { type Refreshed, refreshDiscovery } from "./catalog-service.ts";
|
|
6
|
+
import type { Synced } from "./agent-links.ts";
|
|
7
|
+
import { activate, resetProfile, withProfilesLock } from "./profile-service.ts";
|
|
8
|
+
import { configFile, profilesDir, projectsFile } from "./profile-store.ts";
|
|
9
|
+
import { ensurePrivateDir } from "../infra/store.ts";
|
|
10
|
+
|
|
11
|
+
/** True when profile `name` has a file (`default` exists as a name even before it has one). */
|
|
12
|
+
export const hasProfileFile = (name: string): boolean => existsSync(join(profilesDir(), `${name}.json`));
|
|
13
|
+
|
|
14
|
+
/** A JSON file written by catherd 0.x: readable JSON with no `schema` field. */
|
|
15
|
+
function isLegacy(file: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const v = JSON.parse(readFileSync(file, "utf8")) as { schema?: unknown } | null;
|
|
18
|
+
return typeof v === "object" && v !== null && v.schema === undefined;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Spec D2, a clean break: 1.0 never reads a 0.x config.json, projects.json or profile, so `init` moves
|
|
26
|
+
* them to `<config>/0.x-backup-<stamp>/`, under the profiles lock. Returns the files moved.
|
|
27
|
+
*/
|
|
28
|
+
export function moveLegacy(now: Date = new Date()): string[] {
|
|
29
|
+
return withProfilesLock(() => moveLegacyUnlocked(now));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function moveLegacyUnlocked(now: Date): string[] {
|
|
33
|
+
const candidates = [
|
|
34
|
+
configFile(),
|
|
35
|
+
projectsFile(),
|
|
36
|
+
...(existsSync(profilesDir())
|
|
37
|
+
? readdirSync(profilesDir())
|
|
38
|
+
.filter((f) => f.endsWith(".json"))
|
|
39
|
+
.map((f) => join(profilesDir(), f))
|
|
40
|
+
: []),
|
|
41
|
+
].filter((f) => existsSync(f) && isLegacy(f));
|
|
42
|
+
if (candidates.length === 0) return [];
|
|
43
|
+
const backup = join(configDir(), `0.x-backup-${now.toISOString().replace(/[:.]/g, "-")}`);
|
|
44
|
+
ensurePrivateDir(join(backup, "profiles"));
|
|
45
|
+
return candidates.map((f) => {
|
|
46
|
+
const to = join(backup, f.startsWith(profilesDir()) ? join("profiles", basename(f)) : basename(f));
|
|
47
|
+
renameSync(f, to);
|
|
48
|
+
return to;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface InitResult {
|
|
53
|
+
moved: string[];
|
|
54
|
+
profile: string;
|
|
55
|
+
/** true when the default profile was written under `profile` */
|
|
56
|
+
created: boolean;
|
|
57
|
+
/** why the default profile was not written: it does not validate against this machine's catalog */
|
|
58
|
+
errors: Issue[];
|
|
59
|
+
/** false when nothing was made active: the profile was not written and has no file */
|
|
60
|
+
active: boolean;
|
|
61
|
+
synced: Synced | null;
|
|
62
|
+
refreshed: Refreshed[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Spec §8 `catherd init`, the setup half: moves 0.x files aside, writes the default profile (spec §7.2)
|
|
67
|
+
* unless a 1.0 one exists and `overwrite` is not set, makes it active and links its agents, and lists
|
|
68
|
+
* every backend's models. It never throws for a default profile that does not validate here: it writes
|
|
69
|
+
* nothing, returns the errors, and activates the profile only when it has a file.
|
|
70
|
+
*/
|
|
71
|
+
export async function initSetup(
|
|
72
|
+
o: { profile?: string; overwrite?: boolean; now?: Date } = {},
|
|
73
|
+
): Promise<InitResult> {
|
|
74
|
+
const moved = moveLegacy(o.now);
|
|
75
|
+
const profile = o.profile ?? "default";
|
|
76
|
+
let created = false;
|
|
77
|
+
let errors: Issue[] = [];
|
|
78
|
+
if (!hasProfileFile(profile) || o.overwrite === true) {
|
|
79
|
+
const saved = resetProfile(profile);
|
|
80
|
+
created = saved.saved;
|
|
81
|
+
errors = saved.errors;
|
|
82
|
+
}
|
|
83
|
+
const active = hasProfileFile(profile);
|
|
84
|
+
const synced = active ? activate(profile) : null;
|
|
85
|
+
const refreshed = await refreshDiscovery();
|
|
86
|
+
return { moved, profile, created, errors, active, synced, refreshed };
|
|
87
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
4
|
+
import { overlaps } from "../domain/lane.ts";
|
|
5
|
+
import { renderState } from "../domain/state.ts";
|
|
6
|
+
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
7
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
8
|
+
import { gitHead, statusSnapshot } from "../infra/git.ts";
|
|
9
|
+
import { readVersioned, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
10
|
+
import { liveDispatches } from "./dispatches.ts";
|
|
11
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
12
|
+
|
|
13
|
+
/** state.json: the orchestrator's notes that state.md shows beside the live facts. */
|
|
14
|
+
const NotesSchema = z.looseObject({
|
|
15
|
+
schema: z.literal(1),
|
|
16
|
+
next: z.string(),
|
|
17
|
+
lastCheck: z.string().nullable(),
|
|
18
|
+
lastLandedAt: z.string().nullable(),
|
|
19
|
+
});
|
|
20
|
+
export type Notes = z.infer<typeof NotesSchema>;
|
|
21
|
+
export type NotesPatch = Partial<Omit<Notes, "schema">>;
|
|
22
|
+
|
|
23
|
+
const FRESH: Notes = { schema: 1, next: "plan the milestones", lastCheck: null, lastLandedAt: null };
|
|
24
|
+
|
|
25
|
+
export function readNotes(run: Run): Notes {
|
|
26
|
+
try {
|
|
27
|
+
return readVersioned(runPaths(run.dir).stateJson, NotesSchema, 1);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (isCatherdError(e) && e.code === "E_CONFIG_NEWER_SCHEMA") throw e;
|
|
30
|
+
return { ...FRESH };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Rewrites state.json and state.md under the run's state lock, so concurrent writers never lose a
|
|
36
|
+
* field (audit C11). `change` may be a function of the current notes; it runs inside the lock.
|
|
37
|
+
* Throws E_IO_UNEXPECTED, writing nothing, when git fails: state.md must not show a broken git as clean.
|
|
38
|
+
*/
|
|
39
|
+
export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesPatch) = {}): Promise<string> {
|
|
40
|
+
const p = runPaths(run.dir);
|
|
41
|
+
return withFileLock(p.stateJson, async () => {
|
|
42
|
+
// git first: a failed snapshot throws before either file is written, so they never disagree
|
|
43
|
+
const [head, snap] = await Promise.all([gitHead(run.meta.repo), statusSnapshot(run.meta.repo)]);
|
|
44
|
+
const notes = readNotes(run);
|
|
45
|
+
const next: Notes = { ...notes, ...(typeof change === "function" ? change(notes) : change) };
|
|
46
|
+
writeJsonAtomic(p.stateJson, next);
|
|
47
|
+
const live = liveDispatches(run);
|
|
48
|
+
const text = renderState({
|
|
49
|
+
title: run.meta.title,
|
|
50
|
+
head: head ?? "none",
|
|
51
|
+
dirty: Object.keys(snap)
|
|
52
|
+
.sort()
|
|
53
|
+
.map((path) => ({
|
|
54
|
+
path,
|
|
55
|
+
owner: live.find((d) => overlaps([path], d.admit.owns).length > 0)?.admit.name ?? null,
|
|
56
|
+
})),
|
|
57
|
+
running: live.map((d) => ({
|
|
58
|
+
name: d.admit.name,
|
|
59
|
+
rung: d.admit.rung,
|
|
60
|
+
thread: d.admit.thread,
|
|
61
|
+
since: d.admit.admittedAt.slice(11, 16),
|
|
62
|
+
brief: relative(run.dir, dispatchPaths(d.dir).brief),
|
|
63
|
+
})),
|
|
64
|
+
lastCheck: next.lastCheck,
|
|
65
|
+
next: next.next,
|
|
66
|
+
});
|
|
67
|
+
writeTextAtomic(p.state, text);
|
|
68
|
+
return text;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ruling (b): a failed state.md refresh (git broken) never fails the call. The notes still go to state.json
|
|
74
|
+
* under the state lock (state.md stays as it was), and the message comes back as a hint.
|
|
75
|
+
*/
|
|
76
|
+
export async function refreshState(
|
|
77
|
+
run: Run,
|
|
78
|
+
change: NotesPatch | ((n: Notes) => NotesPatch) = {},
|
|
79
|
+
): Promise<{ text: string | null; hints: string[] }> {
|
|
80
|
+
let applied = false;
|
|
81
|
+
const apply = (n: Notes): NotesPatch => {
|
|
82
|
+
applied = true;
|
|
83
|
+
return typeof change === "function" ? change(n) : change;
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
return { text: await updateState(run, apply), hints: [] };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
const hints = [`state.md not refreshed: ${errorMessage(e)}`];
|
|
89
|
+
// git fails before updateState reaches the notes: keep them, so a later refresh still shows them
|
|
90
|
+
if (!applied) {
|
|
91
|
+
const stateJson = runPaths(run.dir).stateJson;
|
|
92
|
+
try {
|
|
93
|
+
await withFileLock(stateJson, () => {
|
|
94
|
+
const notes = readNotes(run);
|
|
95
|
+
writeJsonAtomic(stateJson, { ...notes, ...apply(notes) });
|
|
96
|
+
});
|
|
97
|
+
} catch (e2) {
|
|
98
|
+
// the lock outwaited (a slow git under another refresh): still never fail the call
|
|
99
|
+
hints.push(`notes not saved: ${errorMessage(e2)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { text: null, hints };
|
|
103
|
+
}
|
|
104
|
+
}
|