catherd-cli 1.4.0 → 1.5.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 +93 -30
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- 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/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- 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/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +5 -0
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- package/src/entry/tui/views/status.tsx +6 -2
- 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 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +110 -23
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +49 -9
- package/src/services/treat-likes.ts +19 -3
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -21,10 +21,25 @@ import { tryParseRung } from "./ids.ts";
|
|
|
21
21
|
import { DIFFICULTIES, KINDS } from "./lane.ts";
|
|
22
22
|
import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
|
|
23
23
|
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type Candidate,
|
|
26
|
+
candidates,
|
|
27
|
+
clearsBar,
|
|
28
|
+
defaultLadder,
|
|
29
|
+
type RoutingProfile,
|
|
30
|
+
select,
|
|
31
|
+
} from "./select.ts";
|
|
25
32
|
|
|
26
33
|
export { quotaOf };
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `reach`: also warn about the kinds and difficulties no worker rung clears (spec 1.5 plan 24), which only an
|
|
37
|
+
* explicit `profile validate` asks for.
|
|
38
|
+
*/
|
|
39
|
+
export interface ValidateOptions {
|
|
40
|
+
reach?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
|
|
29
44
|
export interface Issue {
|
|
30
45
|
path: string;
|
|
@@ -114,7 +129,8 @@ export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
|
|
|
114
129
|
/**
|
|
115
130
|
* Whether a rung's scores are catherd's guess: borrowed through a treat-like, filled by an inferred stand-in
|
|
116
131
|
* (spec 1.2 §6.1), or only `inferred` ones. `note` says what a treat-like lends: "scores borrowed from X"
|
|
117
|
-
* when
|
|
132
|
+
* when every value is borrowed, else the dimensions it borrows (a rung a sync scored on some); and (1.2
|
|
133
|
+
* minor) what each inferred stand-in lends: "honesty inferred from Y".
|
|
118
134
|
*/
|
|
119
135
|
export function inferredScores(
|
|
120
136
|
c: Catalog,
|
|
@@ -125,9 +141,17 @@ export function inferredScores(
|
|
|
125
141
|
const records = Object.values(s.records);
|
|
126
142
|
const guessed =
|
|
127
143
|
s.via !== null || s.inferred.length > 0 || records.every((r) => r.confidence === "inferred");
|
|
128
|
-
const all = s.borrowed.length
|
|
129
|
-
const
|
|
130
|
-
|
|
144
|
+
const all = s.borrowed.length === records.length;
|
|
145
|
+
const byStandIn = new Map<string, Dim[]>();
|
|
146
|
+
for (const d of s.inferred) {
|
|
147
|
+
const like = s.standIns[d] as string;
|
|
148
|
+
byStandIn.set(like, [...(byStandIn.get(like) ?? []), d]);
|
|
149
|
+
}
|
|
150
|
+
const parts = [
|
|
151
|
+
...(s.via ? [`${all ? "scores" : s.borrowed.join(", ")} borrowed from ${s.via}`] : []),
|
|
152
|
+
...[...byStandIn].map(([like, dims]) => `${dims.join(", ")} inferred from ${like}`),
|
|
153
|
+
];
|
|
154
|
+
return { inferred: guessed, via: s.via, note: parts.length ? parts.join("; ") : null };
|
|
131
155
|
}
|
|
132
156
|
|
|
133
157
|
/**
|
|
@@ -147,6 +171,7 @@ export function validateProfile(
|
|
|
147
171
|
backends: readonly string[],
|
|
148
172
|
doc?: ProfileDoc,
|
|
149
173
|
host: OrchestrationHost = "unknown",
|
|
174
|
+
o: ValidateOptions = {},
|
|
150
175
|
): Validation {
|
|
151
176
|
const errors: Issue[] = [];
|
|
152
177
|
const warnings: Issue[] = [];
|
|
@@ -172,6 +197,13 @@ export function validateProfile(
|
|
|
172
197
|
message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
|
|
173
198
|
});
|
|
174
199
|
if (!rc.enabled) continue;
|
|
200
|
+
// spec §5: network: false takes a workspace-write role's network grants; read-only and full have none to take
|
|
201
|
+
if (rc.network === false && rc.access !== "workspace-write")
|
|
202
|
+
warnings.push({
|
|
203
|
+
path: `${at}.network`,
|
|
204
|
+
message: `network: false does nothing here: it applies to workspace-write roles, and ${role} runs ${rc.access}`,
|
|
205
|
+
fix: `catherd profile set ${at}.network null`,
|
|
206
|
+
});
|
|
175
207
|
for (const rung of rc.rungs) {
|
|
176
208
|
const native = nativeClaudeIssue(rung, host, `${at}.rungs`);
|
|
177
209
|
if (native) errors.push(native);
|
|
@@ -218,7 +250,7 @@ export function validateProfile(
|
|
|
218
250
|
if (!scoresOf(c, info.canonical))
|
|
219
251
|
warnings.push({
|
|
220
252
|
path: `${at}.rungs`,
|
|
221
|
-
message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it`,
|
|
253
|
+
message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it${rung === rc.defaultRung ? `; it is the ${role}'s default rung, so ${defaultFallback(c, p, role)}` : ""}`,
|
|
222
254
|
fix: TREAT_LIKE_FIX(rung),
|
|
223
255
|
});
|
|
224
256
|
}
|
|
@@ -257,24 +289,48 @@ export function validateProfile(
|
|
|
257
289
|
path: `${at}.rungs`,
|
|
258
290
|
message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
|
|
259
291
|
});
|
|
260
|
-
// spec 1.
|
|
261
|
-
//
|
|
262
|
-
//
|
|
292
|
+
// spec 1.5 plan 24: every kind and difficulty no worker rung clears, in one warning; those lanes start at the
|
|
293
|
+
// default rung (or an easier difficulty's start) and climb only onto rungs at least as strong. Only an
|
|
294
|
+
// explicit validate says it (Ruling 3): the shipped bars put logic and hard above every Sol rung, the
|
|
295
|
+
// owner's call, so every save and doctor would repeat it
|
|
296
|
+
if (o.reach && role === "worker" && usable.length > 1) {
|
|
297
|
+
const unreached = unreachable(c, usable);
|
|
298
|
+
if (unreached.length)
|
|
299
|
+
warnings.push({
|
|
300
|
+
path: `${at}.rungs`,
|
|
301
|
+
message: `no worker rung clears ${unreached.join("; ")}: those lanes start at the default rung and climb only onto rungs at least as strong (route names the closest)`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// spec 1.5 plan 24: a quota whose rungs never start a lane sits idle but for climbs (the identity run's Go).
|
|
305
|
+
// Only the worker routes lanes and climbs (Ruling 9); another role routes lane-less to one rung, so a rung
|
|
306
|
+
// on a second quota is a deliberate spare there, not an idle quota (final review Important 4, Ruling 8)
|
|
263
307
|
if (role === "worker" && usable.length > 1) {
|
|
264
|
-
const
|
|
265
|
-
|
|
308
|
+
const idle = idleQuotas(c, p, role, usable);
|
|
309
|
+
for (const [q, rungs] of idle) {
|
|
310
|
+
// the billing key, not the quota, is what billing is set on (`claude:` and `claude-code:` share one)
|
|
311
|
+
const keys = [...new Set(usable.filter((x) => rungs.includes(x.rung)).map((x) => x.info.key))];
|
|
266
312
|
warnings.push({
|
|
267
313
|
path: `${at}.rungs`,
|
|
268
|
-
message:
|
|
314
|
+
message: `${q} never starts a ${role} lane: ${rungs.join(", ")} ${rungs.length > 1 ? "run" : "runs"} only on a climb, since another rung starts every lane`,
|
|
315
|
+
fix: `check ${keys.map((k) => `billing.${k}`).join(" and ")} (a metered rung starts only where nothing paid from a plan clears the bar), or order ${at}.rungs so its rungs come first among equals`,
|
|
269
316
|
});
|
|
317
|
+
}
|
|
270
318
|
}
|
|
271
319
|
}
|
|
272
320
|
|
|
273
321
|
const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
|
|
274
|
-
// the stand-ins
|
|
322
|
+
// the stand-ins the fixes below may suggest: on a backend catherd runs and the profile already names (a
|
|
323
|
+
// ladder or a failover entry), never one this machine may not have (Cursor on a machine without it); a
|
|
324
|
+
// native claude: rung names claude-code too, the same Claude Code CLI
|
|
325
|
+
const named = new Set(
|
|
326
|
+
[...ladders, ...Object.keys(p.failover), ...Object.values(p.failover)].flatMap((x) => {
|
|
327
|
+
const b = tryParseRung(x)?.backend;
|
|
328
|
+
return b === undefined ? [] : b === "claude" ? [b, "claude-code"] : [b];
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
275
331
|
const pool = catalogRungs(c).filter((x) => {
|
|
276
332
|
const r = tryParseRung(x);
|
|
277
|
-
return r !== null && backends.includes(r.backend);
|
|
333
|
+
return r !== null && backends.includes(r.backend) && named.has(r.backend);
|
|
278
334
|
});
|
|
279
335
|
for (const [from, to] of Object.entries(p.failover)) {
|
|
280
336
|
const at = `failover.${from}`;
|
|
@@ -294,7 +350,8 @@ export function validateProfile(
|
|
|
294
350
|
errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
|
|
295
351
|
continue;
|
|
296
352
|
}
|
|
297
|
-
|
|
353
|
+
const unscored = !scoresOf(c, rungInfo(c, to).canonical);
|
|
354
|
+
if (unscored)
|
|
298
355
|
warnings.push({
|
|
299
356
|
path: at,
|
|
300
357
|
message: `stand-in ${to} is unscored and no rung is near enough to stand in for it`,
|
|
@@ -307,7 +364,8 @@ export function validateProfile(
|
|
|
307
364
|
fix: "name a stand-in on another backend or plan",
|
|
308
365
|
});
|
|
309
366
|
const ranked = rankStandIns(c, p.billing, from, pool);
|
|
310
|
-
|
|
367
|
+
// an unscored stand-in has its own warning above: no "downgrade" on every bar dim besides
|
|
368
|
+
const down = unscored ? [] : downgradeDims(c, from, to);
|
|
311
369
|
if (down.length)
|
|
312
370
|
warnings.push({
|
|
313
371
|
path: at,
|
|
@@ -334,6 +392,44 @@ export function validateProfile(
|
|
|
334
392
|
return { errors, warnings };
|
|
335
393
|
}
|
|
336
394
|
|
|
395
|
+
/** The role's default rung when it is unscored: what routing starts on instead. */
|
|
396
|
+
function defaultFallback(c: Catalog, p: Profile, role: Role): string {
|
|
397
|
+
try {
|
|
398
|
+
return `routing falls back to ${defaultLadder(c, routingProfileOf(p, role), role).rung}`;
|
|
399
|
+
} catch {
|
|
400
|
+
return "routing has no rung to fall back to";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `repo_code logic, hard` per kind: the kinds and difficulties no rung of `usable` clears. */
|
|
405
|
+
function unreachable(c: Catalog, usable: Candidate[]): string[] {
|
|
406
|
+
return KINDS.flatMap((k) => {
|
|
407
|
+
const ds = DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(c, x, k, d)));
|
|
408
|
+
return ds.length ? [`${k} ${ds.join(", ")}`] : [];
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Spec 1.5 plan 24: each quota of the role's usable rungs that no kind and difficulty (nor a lane-less route)
|
|
414
|
+
* ever starts on, whichever quota the run has used least, with its rungs.
|
|
415
|
+
*/
|
|
416
|
+
function idleQuotas(c: Catalog, p: Profile, role: Role, usable: Candidate[]): [string, string[]][] {
|
|
417
|
+
const quotas = [...new Set(usable.map((x) => quotaOf(x.info.parsed)))];
|
|
418
|
+
if (quotas.length < 2) return [];
|
|
419
|
+
const starts = new Set<string>();
|
|
420
|
+
// every quota gets its turn as the least used, so a tie it could win counts as a start
|
|
421
|
+
for (const fresh of [null, ...quotas]) {
|
|
422
|
+
const usage = Object.fromEntries(quotas.map((q) => [q, q === fresh ? 0 : 1]));
|
|
423
|
+
const rp = { ...routingProfileOf(p, role), usage };
|
|
424
|
+
starts.add(defaultLadder(c, rp, role).rung);
|
|
425
|
+
for (const k of KINDS) for (const d of DIFFICULTIES) starts.add(select(c, rp, role, k, d).rung);
|
|
426
|
+
}
|
|
427
|
+
const started = new Set(usable.filter((x) => starts.has(x.rung)).map((x) => quotaOf(x.info.parsed)));
|
|
428
|
+
return quotas
|
|
429
|
+
.filter((q) => !started.has(q))
|
|
430
|
+
.map((q) => [q, usable.filter((x) => quotaOf(x.info.parsed) === q).map((x) => x.rung)]);
|
|
431
|
+
}
|
|
432
|
+
|
|
337
433
|
export function nativeClaudeIssue(rung: string, host: OrchestrationHost, path: string): Issue | null {
|
|
338
434
|
const r = tryParseRung(rung);
|
|
339
435
|
return host !== "claude-code" && r?.backend === "claude"
|
package/src/domain/profile.ts
CHANGED
|
@@ -45,6 +45,7 @@ const RoleDocSchema = z.looseObject({
|
|
|
45
45
|
rungs: z.array(z.string()).optional(),
|
|
46
46
|
defaultRung: z.string().optional(),
|
|
47
47
|
network: z.boolean().optional(),
|
|
48
|
+
timeouts: z.looseObject({ idleMin: positive.optional(), wallMin: positive.optional() }).optional(),
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
export const ProfileDocSchema = z.looseObject({
|
|
@@ -117,8 +118,19 @@ export type RoleConfig = {
|
|
|
117
118
|
defaultRung?: string;
|
|
118
119
|
/** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
|
|
119
120
|
network?: false;
|
|
121
|
+
/** plan 23: this role's own idle and wall timeouts, over the profile's `timeouts` (a long gate, a slow model) */
|
|
122
|
+
timeouts?: { idleMin?: number; wallMin?: number };
|
|
120
123
|
};
|
|
121
124
|
|
|
125
|
+
/** The idle and wall timeouts a role runs under: its own `timeouts`, else the profile's. */
|
|
126
|
+
export const roleTimeouts = (
|
|
127
|
+
profile: { timeouts: { idleMin: number; wallMin: number } },
|
|
128
|
+
role: { timeouts?: { idleMin?: number; wallMin?: number } } | undefined,
|
|
129
|
+
): { idleMin: number; wallMin: number } => ({
|
|
130
|
+
idleMin: role?.timeouts?.idleMin ?? profile.timeouts.idleMin,
|
|
131
|
+
wallMin: role?.timeouts?.wallMin ?? profile.timeouts.wallMin,
|
|
132
|
+
});
|
|
133
|
+
|
|
122
134
|
/** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
|
|
123
135
|
export interface Profile {
|
|
124
136
|
name: string;
|
|
@@ -298,6 +310,14 @@ export function resolveProfile(doc: ProfileDoc, name: string, host: Orchestratio
|
|
|
298
310
|
rungs: [...(d?.rungs ?? omitted)],
|
|
299
311
|
...(defaultRung ? { defaultRung } : {}),
|
|
300
312
|
...(d?.network === false ? { network: false as const } : {}),
|
|
313
|
+
...(d?.timeouts?.idleMin !== undefined || d?.timeouts?.wallMin !== undefined
|
|
314
|
+
? {
|
|
315
|
+
timeouts: {
|
|
316
|
+
...(d.timeouts.idleMin !== undefined ? { idleMin: d.timeouts.idleMin } : {}),
|
|
317
|
+
...(d.timeouts.wallMin !== undefined ? { wallMin: d.timeouts.wallMin } : {}),
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
: {}),
|
|
301
321
|
};
|
|
302
322
|
}
|
|
303
323
|
const harness: Profile["harness"] = {};
|
|
@@ -344,6 +364,10 @@ const RolePatchSchema = z
|
|
|
344
364
|
rungs: z.array(RungSchema),
|
|
345
365
|
defaultRung: RungSchema.nullable(),
|
|
346
366
|
network: z.boolean().nullable(),
|
|
367
|
+
timeouts: z
|
|
368
|
+
.strictObject({ idleMin: positive.nullable(), wallMin: positive.nullable() })
|
|
369
|
+
.partial()
|
|
370
|
+
.nullable(),
|
|
347
371
|
})
|
|
348
372
|
.partial();
|
|
349
373
|
|
package/src/domain/record.ts
CHANGED
|
@@ -6,7 +6,8 @@ export type Access = (typeof ACCESS)[number];
|
|
|
6
6
|
const RUN_STATUSES = ["ok", "failed", "limit", "cli-too-old", "timeout", "cancelled"] as const;
|
|
7
7
|
export type RunStatus = (typeof RUN_STATUSES)[number];
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** plan 23: `flaky`, a worker's word for a check that failed, then passed when run alone */
|
|
10
|
+
const REPLY_STATUSES = ["complete", "partial", "blocked", "refused", "flaky"] as const;
|
|
10
11
|
export type ReplyStatus = (typeof REPLY_STATUSES)[number];
|
|
11
12
|
|
|
12
13
|
export interface Tokens {
|
|
@@ -27,6 +28,8 @@ export const EXIT_REASONS = [
|
|
|
27
28
|
"wall-timeout",
|
|
28
29
|
"cancelled",
|
|
29
30
|
"lost",
|
|
31
|
+
/** plan 23: the provider kept failing (retries with no progress); failover treats it as a usage limit */
|
|
32
|
+
"provider-unavailable",
|
|
30
33
|
] as const;
|
|
31
34
|
export type ExitReason = (typeof EXIT_REASONS)[number];
|
|
32
35
|
export interface ExitInfo {
|
|
@@ -65,6 +68,8 @@ export const RunRecordSchema = z.looseObject({
|
|
|
65
68
|
gitUnavailable: z.boolean().optional(),
|
|
66
69
|
replyStatus: z.enum(REPLY_STATUSES).nullable(),
|
|
67
70
|
replyWhy: z.string().nullable(),
|
|
71
|
+
/** plan 23: the reply's ENV: line, when the environment stopped the role; absent otherwise */
|
|
72
|
+
environment: z.string().optional(),
|
|
68
73
|
threadHeavy: z.boolean(),
|
|
69
74
|
access: z.enum(ACCESS),
|
|
70
75
|
isolated: z.boolean(),
|
|
@@ -79,7 +84,16 @@ export const RunRecordSchema = z.looseObject({
|
|
|
79
84
|
});
|
|
80
85
|
export type RunRecord = z.infer<typeof RunRecordSchema>;
|
|
81
86
|
|
|
82
|
-
const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused)\s*(?:—|–|-)\s*(.*)$/;
|
|
87
|
+
const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused|flaky)\s*(?:—|–|-)\s*(.*)$/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Plan 23: what stopped the role, when the environment did: an `ENV: <what>` line of its reply (a VPN, no
|
|
91
|
+
* Docker, a dead registry); null without one.
|
|
92
|
+
*/
|
|
93
|
+
export function parseReplyEnvironment(reply: string): string | null {
|
|
94
|
+
const m = /^[ \t]*ENV:[ \t]*(\S.*?)[ \t]*$/m.exec(reply);
|
|
95
|
+
return m ? (m[1] as string) : null;
|
|
96
|
+
}
|
|
83
97
|
|
|
84
98
|
/** The role's claim about its own work: the reply's last line, `STATUS: <s> — <why>`. */
|
|
85
99
|
export function parseReplyStatus(reply: string): { status: ReplyStatus | null; why: string | null } {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { parseReplyStatus } from "./record.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plan 22: a final reply never overwrites the report. `finals` are the messages that ended each of the run's
|
|
5
|
+
* turns, in order (a background command's notification can start another turn after the report); `last` is the
|
|
6
|
+
* reply the CLI kept, the last of them. With more than one, the reply is the longest STATUS-bearing message, the
|
|
7
|
+
* later ones after it under `later:`, and the report's STATUS line last, so the record's STATUS is the report's.
|
|
8
|
+
* Otherwise `last` is the reply, unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export function composeReply(finals: string[], last: string): string {
|
|
11
|
+
if (finals.length < 2) return last;
|
|
12
|
+
let report = -1;
|
|
13
|
+
for (const [i, f] of finals.entries())
|
|
14
|
+
if (
|
|
15
|
+
parseReplyStatus(f).status !== null &&
|
|
16
|
+
(report < 0 || f.trim().length >= finals[report]!.trim().length)
|
|
17
|
+
)
|
|
18
|
+
report = i;
|
|
19
|
+
if (report < 0 || report === finals.length - 1) return last;
|
|
20
|
+
const lines = finals[report]!.trimEnd().split("\n");
|
|
21
|
+
const status = lines.pop() as string;
|
|
22
|
+
const later = finals
|
|
23
|
+
.slice(report + 1)
|
|
24
|
+
.map((f) => f.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
if (!later.length) return last;
|
|
27
|
+
const body = lines.join("\n").trimEnd();
|
|
28
|
+
return [...(body ? [body, ""] : []), "later:", later.join("\n\n"), "", status.trim(), ""].join("\n");
|
|
29
|
+
}
|
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
import type { Access } from "./record.ts";
|
|
2
|
+
import { VERIFIER_GATE_RULES } from "./gate-brief.ts";
|
|
3
|
+
import { COORDINATOR_TOOLS } from "./role-scope.ts";
|
|
2
4
|
import type { Role } from "./roles.ts";
|
|
3
5
|
|
|
4
6
|
// The role prompts of the native Claude subagents, ported from 0.x (spec D9). Headless backends get the
|
|
5
7
|
// same text from the orchestrator's briefs; only native agents carry it in their agent file.
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
/**
|
|
10
|
+
* How a role reaches its run folder, naming only the tools that role gets (spec 1.5 plan 21, #42 finding 5): every
|
|
11
|
+
* role reads, the architect and the researcher also write. The CLI forms are the fallback when no tool is listed.
|
|
12
|
+
*/
|
|
13
|
+
const runFiles = (writes: boolean): string =>
|
|
14
|
+
writes
|
|
15
|
+
? "The run folder is outside the project. Read and write it only through catherd's read_run_file and write_run_file, with the run id the orchestrator gave you (native Claude subagents: mcp__plugin_catherd_catherd__<name>; headless Codex and Claude Code roles: mcp__catherd_role__<name>). When neither is listed, run catherd run-file read <run> <path>, and catherd run-file write <run> <path> with the content on stdin. Paths are relative to the run folder."
|
|
16
|
+
: "The run folder is outside the project. Read it only through catherd's read_run_file, with the run id the orchestrator gave you (native Claude subagents: mcp__plugin_catherd_catherd__read_run_file; headless Codex and Claude Code roles: mcp__catherd_role__read_run_file). When neither is listed, run catherd run-file read <run> <path>. Paths are relative to the run folder.";
|
|
9
17
|
|
|
10
18
|
const REPLY =
|
|
11
19
|
"Do not commit. Reply in at most 15 lines: results, file:line, and evidence as a log path, not the log. The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>";
|
|
12
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The worker's reply contract. Plan 22, resume hygiene: a worker's thread may be resumed for its fix round, and a
|
|
23
|
+
* command its last turn left running in the background ends the resumed CLI (exit 143). Plan 23: acceptance that
|
|
24
|
+
* builds from HEAD is the verifier's, since a worker does not commit; an environment that stopped it goes on an ENV:
|
|
25
|
+
* line (climb refuses it); a check that failed, then passed alone, is STATUS: flaky with that evidence.
|
|
26
|
+
*/
|
|
27
|
+
const WORKER_REPLY =
|
|
28
|
+
"Before you reply, leave nothing running: stop every server, watcher or command you started in the background. Do not commit. Acceptance items that build from HEAD (git archive, a commit's image) belong to the verifier: do not run them, and never commit to get them to run. Reply in at most 15 lines: results, file:line, and evidence as a log path, not the log. When the environment stopped you (no Docker, a VPN, a dead registry), add a line ENV: <what>. The last line of your reply is: STATUS: complete|partial|blocked|refused|flaky — <one line why>; flaky means a check failed, then passed when run alone: name it, and how often each.";
|
|
29
|
+
|
|
13
30
|
const architect = [
|
|
14
31
|
"You are the architect of a catherd run. The orchestrator gave you the goal, the acceptance lines, the run id, and usually a dossier: a researcher's map of the code this work touches. Workers are other models that run in the project directory with no memory of this conversation. They will write every line of code from your plan.",
|
|
15
32
|
"",
|
|
16
|
-
`Start from the dossier. Open a project file yourself only to settle a decision the dossier leaves open, and read each file once. Bash is for inspection only. Change nothing in the project. ${
|
|
33
|
+
`Start from the dossier. Open a project file yourself only to settle a decision the dossier leaves open, and read each file once. Bash is for inspection only. Change nothing in the project. ${runFiles(true)}`,
|
|
17
34
|
"",
|
|
18
35
|
"When the acceptance lines include plan: <path>[, <path>…], the user already has a plan and there is no dossier. Translate it, do not design: each plan task becomes lanes, each merge request or phase a milestone with its full check. Copy the plan's decisions into plan.md and the lane files, and decide only what the plan leaves undecided.",
|
|
19
36
|
"",
|
|
@@ -24,7 +41,7 @@ const architect = [
|
|
|
24
41
|
"3. Lanes inside each milestone, M1.L1…: each lane gives",
|
|
25
42
|
" - the files it owns (two lanes of one milestone never share a file);",
|
|
26
43
|
" - what changes, stated as behavior plus the exact signatures and data shapes it introduces;",
|
|
27
|
-
" - its fast check: the command a worker reruns while it works, seconds to a minute or two: its targeted tests plus the linter, and the type check when the project has one, scoped to
|
|
44
|
+
" - its fast check: the command a worker reruns while it works, seconds to a minute or two: its targeted tests plus the linter of every package the lane touches (each package's own: golangci-lint for a Go module, its lint script for a JS one), and the type check when the project has one, scoped to those packages.",
|
|
28
45
|
"",
|
|
29
46
|
" Every lane of a milestone runs at the same time, so split for width: more small lanes beat one long one.",
|
|
30
47
|
"4. Speed: if the full check takes more than about five minutes, name why and make speeding it up (parallel tests, one shared fixture, fewer real-time waits) a lane of the first milestone.",
|
|
@@ -41,6 +58,8 @@ const architect = [
|
|
|
41
58
|
"",
|
|
42
59
|
"catherd reads the Owns: line to keep two running lanes off the same file, and to tell a refusal (owned files unchanged) from work. It reads Kind: and Difficulty: to pick the lane's model when its router is unsure.",
|
|
43
60
|
"",
|
|
61
|
+
"Two optional lines follow the five when they apply: `After: Mx.Ly, …` when the lane compiles against another lane's changes (catherd dispatches it only once those finish), and `Allow: path[:line], …` under a fast check that greps for absence, naming the hits that are allowed exceptions. Write such greps with word boundaries.",
|
|
62
|
+
"",
|
|
44
63
|
"Signatures, data shapes and test case names are yours. Function bodies are the worker's. A plan that contains the implementation turns the worker into a typist and spends the most expensive model on typing.",
|
|
45
64
|
"",
|
|
46
65
|
"Prefer the smallest design that meets the acceptance lines: no abstraction with one implementation, no config for a value that never changes, no comments restating code.",
|
|
@@ -54,17 +73,21 @@ const verifier = [
|
|
|
54
73
|
"You verify work you did not write. You get the acceptance lines, the check command and how to run the thing. You do not get the author's account of it, and you should not look for one.",
|
|
55
74
|
"",
|
|
56
75
|
"1. Run the check command once. Report its exit code and the failing lines. When the check has several gate items (suites, lint, builds, a boot check):",
|
|
57
|
-
" -
|
|
58
|
-
" -
|
|
59
|
-
" -
|
|
76
|
+
" - First call gate_check with only the run id and the milestone: it lists the items already recorded for it (reuse their names; re-check the failed ones first, each command capped at 10 minutes) and the repo's gate environment (export it before the first item; a $NAME value is a secret in that env var).",
|
|
77
|
+
" - Before each item, call the catherd MCP tool gate_check (mcp__catherd_role__gate_check for native headless Codex/Claude Code roles; mcp__plugin_catherd_catherd__gate_check for native Claude subagents) with the run id, the milestone you verify (M1, as your brief names it), the item, its command and the repo paths it depends on. When it answers carried: true, do not run the item: report it as carried over from its commit. It also tells the orchestrator which step you are on.",
|
|
78
|
+
" - After an item passes, call gate_pass (mcp__catherd_role__gate_pass, or mcp__plugin_catherd_catherd__gate_pass for native Claude subagents) with the same item, command and paths, and the evidence.",
|
|
79
|
+
" - When neither tool is listed, run the same from your shell: catherd gate check <run> --milestone <M> --item <item> --command <command> --paths <path,…>, then catherd gate pass <run> --item <item> --command <command> --paths <path,…> --evidence <evidence>.",
|
|
80
|
+
" - Run independent items side by side, each heavy one wrapped in catherd lock --role verifier -- <command>, which queues them within the machine's slots and keeps another run's verifier from running at the same time.",
|
|
60
81
|
" - Build each commit's images once, and reuse them for the boot check and the acceptance suite.",
|
|
61
82
|
"2. Exercise every acceptance line through the real entry point: the CLI, the HTTP route, the page. Read source only to find that entry point. When a line needs data or files, build them in a temporary directory outside the project.",
|
|
62
83
|
"3. Read the diff (git diff plus untracked files) for bugs the acceptance lines miss: wrong edge behavior, dead code, leftovers.",
|
|
63
84
|
"",
|
|
64
85
|
"Change nothing in the project. Do not write mutation tests or extra proof tests. The job is to find out whether the work is right, not to grade its test suite.",
|
|
65
86
|
"",
|
|
87
|
+
...VERIFIER_GATE_RULES,
|
|
88
|
+
"",
|
|
66
89
|
"Return, in this order:",
|
|
67
|
-
"- VERDICT: PASS or VERDICT: FAIL on the first line.",
|
|
90
|
+
"- VERDICT: PASS or VERDICT: FAIL on the first line, or VERDICT: BLOCKED: environment — <the probe> when the machine stopped the gate.",
|
|
68
91
|
"- One line per acceptance line: A<n> PASS|FAIL, the command you ran and the decisive output.",
|
|
69
92
|
"- One line per gate item: PASS|FAIL, or carried over from <commit>.",
|
|
70
93
|
"- Bugs outside the acceptance lines: file:line, what happens, the input that triggers it.",
|
|
@@ -75,11 +98,11 @@ const worker = (version: string) =>
|
|
|
75
98
|
[
|
|
76
99
|
"You are a worker in a catherd run. The orchestrator's message is your brief: the acceptance lines, the lane file to read, the files you own and the files you must not touch, and your fast check.",
|
|
77
100
|
"",
|
|
78
|
-
`Read your lane file first. ${
|
|
101
|
+
`Read your lane file first. ${runFiles(false)} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
|
|
79
102
|
"",
|
|
80
103
|
`Change only the files you own. Run your fast check until it passes. Run the full suite only if the brief says so, and wrap any full build or full test suite in: bunx catherd-cli@${version} lock -- <command>. Other lanes share this machine.`,
|
|
81
104
|
"",
|
|
82
|
-
|
|
105
|
+
WORKER_REPLY,
|
|
83
106
|
].join("\n");
|
|
84
107
|
|
|
85
108
|
const reviewer = [
|
|
@@ -123,7 +146,7 @@ const writer = [
|
|
|
123
146
|
const researcher = [
|
|
124
147
|
"You answer a factual question about the code, or map it for a dossier. Change nothing in the project. Give file:line for every claim.",
|
|
125
148
|
"",
|
|
126
|
-
`A dossier lists: the files and folders involved, one line each on what they hold; the existing patterns the work should copy, by path; the build, test and run commands, with how long the full suite takes when the docs, the CI config or a log say so (never run the suite to find out; write "unknown" instead); the lint and type-check commands, and how to scope each to one package; the symbols the change will call or alter, with their signatures; the project rules (CLAUDE.md, AGENTS.md, conventions) that bind this work; and risks: shared files, generated code, slow or flaky tests. It may run to 200 lines. Write it with write_run_file to the path the brief names, and reply with that path. ${
|
|
149
|
+
`A dossier lists: the files and folders involved, one line each on what they hold; the existing patterns the work should copy, by path; the build, test and run commands, with how long the full suite takes when the docs, the CI config or a log say so (never run the suite to find out; write "unknown" instead); the lint and type-check commands, and how to scope each to one package; the symbols the change will call or alter, with their signatures; the project rules (CLAUDE.md, AGENTS.md, conventions) that bind this work; and risks: shared files, generated code, slow or flaky tests. It may run to 200 lines. Write it with write_run_file to the path the brief names, and reply with that path. ${runFiles(true)}`,
|
|
127
150
|
"",
|
|
128
151
|
"For a single question, reply in at most 15 lines. The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>",
|
|
129
152
|
].join("\n");
|
|
@@ -148,8 +171,8 @@ const STATUS_LINE =
|
|
|
148
171
|
*/
|
|
149
172
|
const CONTRACTS: Record<Role, string> = {
|
|
150
173
|
architect: `Reply briefly: the milestones, each with its lanes as Mx.Ly — one line — owned files, and the full-check command. ${STATUS_LINE}`,
|
|
151
|
-
verifier: `The first line of your reply is VERDICT: PASS or VERDICT: FAIL. ${STATUS_LINE}`,
|
|
152
|
-
worker:
|
|
174
|
+
verifier: `The first line of your reply is VERDICT: PASS or VERDICT: FAIL, or VERDICT: BLOCKED: environment — <the probe that proves it> when the machine, not the work, stops the gate (run that probe twice, 5 s apart, before you call the host blocked). ${STATUS_LINE}`,
|
|
175
|
+
worker: WORKER_REPLY,
|
|
153
176
|
reviewer: REPLY,
|
|
154
177
|
"ui-reviewer": REPLY,
|
|
155
178
|
artist: REPLY,
|
|
@@ -169,10 +192,27 @@ export function withReplyContract(role: Role, brief: string): string {
|
|
|
169
192
|
/** The role's prompt; the worker's names the catherd version whose `lock` it must use. */
|
|
170
193
|
export const rolePrompt = (role: Role, version: string): string => BODIES[role](version);
|
|
171
194
|
|
|
195
|
+
/** The catherd plugin's MCP tool names as a native Claude subagent sees them. */
|
|
196
|
+
export const NATIVE_TOOL_PREFIX = "mcp__plugin_catherd_catherd__";
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The catherd tools a native subagent may never call: the coordinator tools, and record_agent_run, which only the
|
|
200
|
+
* orchestrator calls (a native verifier recording its own `ok` would satisfy land's evidence).
|
|
201
|
+
*/
|
|
202
|
+
const NATIVE_FORBIDDEN = [...COORDINATOR_TOOLS, "record_agent_run"] as const;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Spec 1.5 plan 21: a native Claude subagent shares the orchestrator's MCP server, so it has no env of its own to
|
|
206
|
+
* refuse it by. Its agent file forbids the coordinator tools and record_agent_run, and its prompt says why.
|
|
207
|
+
*/
|
|
208
|
+
export const NOT_THE_ORCHESTRATOR = `You are a role of a catherd run, not its orchestrator. Never call catherd's ${NATIVE_FORBIDDEN.join(", ")}: they belong to the orchestrator, which reads your reply.`;
|
|
209
|
+
|
|
172
210
|
/**
|
|
173
211
|
* Spec D10 for native subagents, whose only lever is the agent file's tool list: read-only drops the
|
|
174
212
|
* editing tools (its Bash stays, for inspection, so enforcement is advisory); every role drops Agent,
|
|
175
|
-
* so a role never spawns its own subagents.
|
|
213
|
+
* so a role never spawns its own subagents, and catherd's coordinator tools and record_agent_run (spec 1.5 plan 21).
|
|
176
214
|
*/
|
|
177
|
-
export const nativeDisallowedTools = (access: Access): string[] =>
|
|
178
|
-
access === "read-only" ? ["Write", "Edit", "NotebookEdit", "Agent"] : ["Agent"]
|
|
215
|
+
export const nativeDisallowedTools = (access: Access): string[] => [
|
|
216
|
+
...(access === "read-only" ? ["Write", "Edit", "NotebookEdit", "Agent"] : ["Agent"]),
|
|
217
|
+
...NATIVE_FORBIDDEN.map((tool) => `${NATIVE_TOOL_PREFIX}${tool}`),
|
|
218
|
+
];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { CatherdError } from "./errors.ts";
|
|
2
|
+
import { ID_PATTERN } from "./ids.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spec 1.5 plan 21: the env var the supervisor sets in every role's process, `<run>/<role-name>`. A process
|
|
6
|
+
* that carries it is a role of that run: it never owns the run and never reaches the coordinator tools.
|
|
7
|
+
*/
|
|
8
|
+
export const ROLE_ENV = "CATHERD_ROLE";
|
|
9
|
+
|
|
10
|
+
/** The run and the dispatch name a role process works for. */
|
|
11
|
+
export interface RoleScope {
|
|
12
|
+
run: string;
|
|
13
|
+
name: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isId = (s: string): boolean => ID_PATTERN.test(s) && !s.includes("..");
|
|
17
|
+
|
|
18
|
+
export const formatRoleScope = (s: RoleScope): string => `${s.run}/${s.name}`;
|
|
19
|
+
|
|
20
|
+
/** `<run>/<role-name>`, else null (unset, empty, or not two ids). */
|
|
21
|
+
export function parseRoleScope(value: string | undefined): RoleScope | null {
|
|
22
|
+
if (!value) return null;
|
|
23
|
+
const [run, name, ...rest] = value.split("/");
|
|
24
|
+
if (rest.length > 0 || !run || !name || !isId(run) || !isId(name)) return null;
|
|
25
|
+
return { run, name };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A run id as `createRun` makes it: `YYYYMMDD-HHMMSS-<slug>`. */
|
|
29
|
+
const RUN_ID = /^\d{8}-\d{6}-/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The role a temp dir names, when it is a role's scratch, `…/runs/<run>/scratch/<name>[/…]`: Codex passes an
|
|
33
|
+
* MCP server only a short list of env vars, TMPDIR among them and CATHERD_ROLE not, so a server a role's
|
|
34
|
+
* Codex starts knows it is a role by its TMPDIR.
|
|
35
|
+
*/
|
|
36
|
+
export function roleScopeOfScratch(tmpdir: string | undefined): RoleScope | null {
|
|
37
|
+
if (!tmpdir) return null;
|
|
38
|
+
const parts = tmpdir.split(/[\\/]+/).filter(Boolean);
|
|
39
|
+
for (let i = parts.length - 2; i >= 2; i--) {
|
|
40
|
+
if (parts[i] !== "scratch" || parts[i - 2] !== "runs") continue;
|
|
41
|
+
const run = parts[i - 1] as string;
|
|
42
|
+
const name = parts[i + 1] as string;
|
|
43
|
+
if (RUN_ID.test(run)) return parseRoleScope(`${run}/${name}`);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The role this process works for: CATHERD_ROLE, else a TMPDIR that is a role's scratch, else null. */
|
|
49
|
+
export function roleScopeFromEnv(env: Record<string, string | undefined>): RoleScope | null {
|
|
50
|
+
return parseRoleScope(env[ROLE_ENV]) ?? roleScopeOfScratch(env.TMPDIR);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The tools only the orchestrator calls: each one claims the run, steers a role or changes the run's plan
|
|
55
|
+
* (spec 1.5 plan 21). A role process gets E_ROLE_SCOPE for them.
|
|
56
|
+
*/
|
|
57
|
+
export const COORDINATOR_TOOLS = [
|
|
58
|
+
"peek",
|
|
59
|
+
"result",
|
|
60
|
+
"dispatch",
|
|
61
|
+
"run_start",
|
|
62
|
+
"climb",
|
|
63
|
+
"land",
|
|
64
|
+
"park",
|
|
65
|
+
"cancel",
|
|
66
|
+
"set_next",
|
|
67
|
+
"answer",
|
|
68
|
+
"profile_set",
|
|
69
|
+
"workspace_start",
|
|
70
|
+
"workspace_contract",
|
|
71
|
+
"workspace_child_start",
|
|
72
|
+
"test_push",
|
|
73
|
+
"workspace_budget",
|
|
74
|
+
"workspace_pause",
|
|
75
|
+
"workspace_resume",
|
|
76
|
+
"lane_set",
|
|
77
|
+
"owns_add",
|
|
78
|
+
"run_pin",
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
const COORDINATOR = new Set<string>(COORDINATOR_TOOLS);
|
|
82
|
+
|
|
83
|
+
export const isCoordinatorTool = (tool: string): boolean => COORDINATOR.has(tool);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a role process may not make this call: every coordinator tool, except a `peek` of its own
|
|
87
|
+
* dispatch (its run and its own name) and a `workspace_contract` read (no `content`).
|
|
88
|
+
*/
|
|
89
|
+
export function refusedForRole(tool: string, args: unknown, scope: RoleScope): boolean {
|
|
90
|
+
if (!COORDINATOR.has(tool)) return false;
|
|
91
|
+
const a = (args ?? {}) as Record<string, unknown>;
|
|
92
|
+
if (tool === "peek") return !(a.run === scope.run && a.name === scope.name);
|
|
93
|
+
if (tool === "workspace_contract") return a.content !== undefined;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** E_ROLE_SCOPE: the error a role process gets for a coordinator tool, with the line that fixes it. */
|
|
98
|
+
export function roleScopeError(tool: string, scope: RoleScope): CatherdError {
|
|
99
|
+
return new CatherdError(
|
|
100
|
+
"E_ROLE_SCOPE",
|
|
101
|
+
`${tool} is the orchestrator's: this process runs ${scope.name} of run ${scope.run}`,
|
|
102
|
+
{
|
|
103
|
+
fix: `a role reports through its reply; read its run's files with read_run_file, or catherd run-file read ${scope.run} <path>`,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Role } from "./roles.ts";
|
|
2
|
+
|
|
3
|
+
export interface RoleMcpContext {
|
|
4
|
+
run: string;
|
|
5
|
+
role: Role;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const ROLE_MCP_SERVER = "catherd_role";
|
|
9
|
+
|
|
10
|
+
/** Spec 1.5 plan 21: the backends whose harness gets the role server, isolated or not. */
|
|
11
|
+
export const ROLE_SERVER_BACKENDS: readonly string[] = ["codex", "claude-code"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Spec 1.5 plan 21: the backends whose sandbox catherd grants the role's scratch, so they get it as TMPDIR.
|
|
15
|
+
* Cursor, Grok and agy write their grants once per isolated home, not per dispatch: they keep the inherited one.
|
|
16
|
+
*/
|
|
17
|
+
export const SCRATCH_BACKENDS: readonly string[] = ["codex", "claude-code", "opencode"];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a role on `backend` gets its scratch as TMPDIR. opencode only when isolated (plan 21 ruling 22): without
|
|
21
|
+
* --standalone its tools run in the shared background service, whose env the client cannot set.
|
|
22
|
+
*/
|
|
23
|
+
export function grantsScratch(backend: string, isolated: boolean): boolean {
|
|
24
|
+
if (backend === "opencode") return isolated;
|
|
25
|
+
return SCRATCH_BACKENDS.includes(backend);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Run artifacts and gate evidence have their own authority, independent of repository writes. */
|
|
29
|
+
export function roleMcpTools(role: Role): string[] {
|
|
30
|
+
const tools = ["read_run_file", "read_knowledge"];
|
|
31
|
+
if (role === "architect" || role === "researcher") tools.push("write_run_file");
|
|
32
|
+
if (role === "verifier") tools.push("gate_check", "gate_pass");
|
|
33
|
+
return tools;
|
|
34
|
+
}
|