catherd-cli 0.2.1 → 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 -26
- 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 -84
- 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,383 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Budget } from "./budget.ts";
|
|
3
|
+
import { BILLING_KEYS, type BillingKey } from "./catalog.ts";
|
|
4
|
+
import { BILLING_MODES, type BillingMode, DEFAULT_BILLING } from "./cost.ts";
|
|
5
|
+
import { CatherdError } from "./errors.ts";
|
|
6
|
+
import { ADAPTER_IDS, parseRung, tryParseRung } from "./ids.ts";
|
|
7
|
+
import { ACCESS, type Access } from "./record.ts";
|
|
8
|
+
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
9
|
+
import { isPlain, slug } from "./util.ts";
|
|
10
|
+
|
|
11
|
+
/** Spec §3.4: every file carries its schema version; a profile is schema 1. */
|
|
12
|
+
const PROFILE_SCHEMA = 1;
|
|
13
|
+
|
|
14
|
+
/** Profile names end up in agent names (`catherd-<profile>-…`), which Claude Code wants lowercase. */
|
|
15
|
+
export const PROFILE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
16
|
+
|
|
17
|
+
export function assertProfileName(name: string): string {
|
|
18
|
+
if (!PROFILE_NAME.test(name))
|
|
19
|
+
throw new CatherdError("E_INPUT_INVALID", `bad profile name "${name}"`, {
|
|
20
|
+
fix: "use lowercase letters, digits and '-', at most 32, starting with a letter or digit",
|
|
21
|
+
});
|
|
22
|
+
return name;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const NOTIFY = ["milestone", "finish", "blocked"] as const;
|
|
26
|
+
export type NotifyMoment = (typeof NOTIFY)[number];
|
|
27
|
+
|
|
28
|
+
/** The harnesses a profile can isolate (spec §7.1 `harness`); the native `claude` path has none. */
|
|
29
|
+
export const HARNESS_KEYS = ADAPTER_IDS;
|
|
30
|
+
|
|
31
|
+
const isRung = (s: string): boolean => tryParseRung(s) !== null;
|
|
32
|
+
/** A `<backend>:<model>#<effort>` rung, for input schemas; stored profiles keep any string and validate. */
|
|
33
|
+
const RungSchema = z.string().refine(isRung, "a rung is <backend>:<model>#<effort>");
|
|
34
|
+
|
|
35
|
+
const positive = z.number().positive();
|
|
36
|
+
|
|
37
|
+
// Stored files: every level is loose, so fields a newer catherd wrote survive a rewrite (spec §3.4), and
|
|
38
|
+
// rung strings are checked by validateProfile rather than making the whole file unreadable. The same holds
|
|
39
|
+
// for values: a stored enum takes any string, resolveProfile reads one it does not know as STORED_FALLBACK
|
|
40
|
+
// says, validateProfile warns, and the file keeps it.
|
|
41
|
+
const RoleDocSchema = z.looseObject({
|
|
42
|
+
enabled: z.boolean().optional(),
|
|
43
|
+
access: z.string().optional(),
|
|
44
|
+
rungs: z.array(z.string()).optional(),
|
|
45
|
+
defaultRung: z.string().optional(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const ProfileDocSchema = z.looseObject({
|
|
49
|
+
schema: z.literal(PROFILE_SCHEMA),
|
|
50
|
+
name: z.string().optional(),
|
|
51
|
+
objective: z.string().optional(),
|
|
52
|
+
jev: z.looseObject({ use: z.string().optional() }).optional(),
|
|
53
|
+
billing: z.record(z.string(), z.string()).optional(),
|
|
54
|
+
roles: z.record(z.string(), RoleDocSchema).optional(),
|
|
55
|
+
harness: z.record(z.string(), z.looseObject({ isolated: z.boolean().optional() })).optional(),
|
|
56
|
+
failover: z.record(z.string(), z.string()).optional(),
|
|
57
|
+
budget: z
|
|
58
|
+
.looseObject({ minutes: positive.optional(), tokens: positive.optional(), usd: positive.optional() })
|
|
59
|
+
.optional(),
|
|
60
|
+
timeouts: z.looseObject({ idleMin: positive.optional(), wallMin: positive.optional() }).optional(),
|
|
61
|
+
preflight: z.looseObject({ confirm: z.boolean().optional() }).optional(),
|
|
62
|
+
lock: z
|
|
63
|
+
.looseObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]).optional() })
|
|
64
|
+
.optional(),
|
|
65
|
+
notify: z.array(z.string()).optional(),
|
|
66
|
+
});
|
|
67
|
+
export type ProfileDoc = z.infer<typeof ProfileDocSchema>;
|
|
68
|
+
|
|
69
|
+
const OBJECTIVES = ["cost", "speed"] as const;
|
|
70
|
+
const JEV_USES = ["auto", "off"] as const;
|
|
71
|
+
const known = <T extends string>(values: readonly T[], v: string | undefined): v is T =>
|
|
72
|
+
v !== undefined && (values as readonly string[]).includes(v);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How a stored value this catherd does not know (a newer one wrote it) is read, always the cautious way:
|
|
76
|
+
* access as read-only, billing as the key's default mode, Jev as off, so nothing is sent or written that
|
|
77
|
+
* the value may not allow; an unknown notify moment is skipped.
|
|
78
|
+
*/
|
|
79
|
+
const STORED_FALLBACK = {
|
|
80
|
+
objective: "cost",
|
|
81
|
+
jevUse: "off",
|
|
82
|
+
access: "read-only",
|
|
83
|
+
billing: (key: string): BillingMode => DEFAULT_BILLING[key as BillingKey] ?? "metered",
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
/** A stored value this catherd does not know, with what it is read as. */
|
|
87
|
+
export interface UnknownValue {
|
|
88
|
+
path: string;
|
|
89
|
+
value: string;
|
|
90
|
+
readAs: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every stored enum value in `doc` this catherd does not know (spec §3.4: newer files stay readable). */
|
|
94
|
+
export function unknownValues(doc: ProfileDoc): UnknownValue[] {
|
|
95
|
+
const out: UnknownValue[] = [];
|
|
96
|
+
const check = (path: string, values: readonly string[], v: string | undefined, readAs: string) => {
|
|
97
|
+
if (v !== undefined && !values.includes(v)) out.push({ path, value: v, readAs });
|
|
98
|
+
};
|
|
99
|
+
check("objective", OBJECTIVES, doc.objective, STORED_FALLBACK.objective);
|
|
100
|
+
check("jev.use", JEV_USES, doc.jev?.use, STORED_FALLBACK.jevUse);
|
|
101
|
+
for (const [k, v] of Object.entries(doc.billing ?? {}))
|
|
102
|
+
check(`billing.${k}`, BILLING_MODES, v, STORED_FALLBACK.billing(k));
|
|
103
|
+
for (const role of ROLES)
|
|
104
|
+
check(`roles.${role}.access`, ACCESS, doc.roles?.[role]?.access, STORED_FALLBACK.access);
|
|
105
|
+
for (const n of doc.notify ?? []) check("notify", NOTIFY, n, "skipped");
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// a type, not an interface, so the built-in roles can be written into a loose (indexed) document
|
|
110
|
+
export type RoleConfig = {
|
|
111
|
+
enabled: boolean;
|
|
112
|
+
access: Access;
|
|
113
|
+
/** in ladder order, each `backend:model#effort`; `claude:` runs as a native subagent, `claude-code:` headless */
|
|
114
|
+
rungs: string[];
|
|
115
|
+
defaultRung?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
|
|
119
|
+
export interface Profile {
|
|
120
|
+
name: string;
|
|
121
|
+
objective: "cost" | "speed";
|
|
122
|
+
jev: { use: "auto" | "off" };
|
|
123
|
+
billing: Record<string, BillingMode>;
|
|
124
|
+
roles: Record<Role, RoleConfig>;
|
|
125
|
+
harness: Record<string, { isolated: boolean }>;
|
|
126
|
+
failover: Record<string, string>;
|
|
127
|
+
budget: Budget;
|
|
128
|
+
timeouts: { idleMin: number; wallMin: number };
|
|
129
|
+
preflight: { confirm: boolean };
|
|
130
|
+
lock: { heavy: number | "cpus/2" };
|
|
131
|
+
notify: NotifyMoment[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const LUNA_HIGH = "codex:gpt-6-luna#high";
|
|
135
|
+
const SOL = (effort: string) => `codex:gpt-6-sol#${effort}`;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Spec §7.2, the owner's setup (Codex on a ChatGPT plan, a Claude plan, OpenCode Go). A role missing
|
|
139
|
+
* from a profile takes its entry here. Architect and verifier run as native Claude subagents (D3).
|
|
140
|
+
*/
|
|
141
|
+
export const BUILTIN_ROLES: Record<Role, RoleConfig> = {
|
|
142
|
+
architect: { enabled: true, access: DEFAULT_ACCESS.architect, rungs: ["claude:claude-opus-5-5#high"] },
|
|
143
|
+
verifier: { enabled: true, access: DEFAULT_ACCESS.verifier, rungs: ["claude:claude-opus-5-5#low"] },
|
|
144
|
+
worker: {
|
|
145
|
+
enabled: true,
|
|
146
|
+
access: DEFAULT_ACCESS.worker,
|
|
147
|
+
rungs: [LUNA_HIGH, SOL("medium"), SOL("high"), SOL("xhigh")],
|
|
148
|
+
defaultRung: SOL("medium"),
|
|
149
|
+
},
|
|
150
|
+
reviewer: { enabled: true, access: DEFAULT_ACCESS.reviewer, rungs: [SOL("high")] },
|
|
151
|
+
"ui-reviewer": { enabled: true, access: DEFAULT_ACCESS["ui-reviewer"], rungs: [SOL("medium")] },
|
|
152
|
+
artist: { enabled: true, access: DEFAULT_ACCESS.artist, rungs: [SOL("medium")] },
|
|
153
|
+
writer: { enabled: true, access: DEFAULT_ACCESS.writer, rungs: [LUNA_HIGH] },
|
|
154
|
+
researcher: { enabled: true, access: DEFAULT_ACCESS.researcher, rungs: [LUNA_HIGH] },
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Spec §7.2: each Codex rung fails over to the best-matching OpenCode Go model. Go serves GPT-6 Luna
|
|
159
|
+
* itself; for Sol it has no GPT model, so Kimi K3 stands in through a shipped treat-like (scores.json),
|
|
160
|
+
* which `profile show` marks inferred.
|
|
161
|
+
*/
|
|
162
|
+
export const DEFAULT_FAILOVER: Record<string, string> = {
|
|
163
|
+
[LUNA_HIGH]: "opencode:opencode-go/gpt-6-luna#high",
|
|
164
|
+
[SOL("medium")]: "opencode:opencode-go/kimi-k3#max",
|
|
165
|
+
[SOL("high")]: "opencode:opencode-go/kimi-k3#max",
|
|
166
|
+
[SOL("xhigh")]: "opencode:opencode-go/kimi-k3#max",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
|
|
170
|
+
const WRITTEN_BILLING: BillingKey[] = ["codex", "claude", "claude-code", "opencode-go", "opencode"];
|
|
171
|
+
|
|
172
|
+
/** The full default profile document, every field written out: what `init` and `profile new` save. */
|
|
173
|
+
export function defaultProfileDoc(name = "default"): ProfileDoc {
|
|
174
|
+
return {
|
|
175
|
+
schema: PROFILE_SCHEMA,
|
|
176
|
+
name,
|
|
177
|
+
objective: "cost",
|
|
178
|
+
jev: { use: "auto" },
|
|
179
|
+
billing: Object.fromEntries(WRITTEN_BILLING.map((k) => [k, DEFAULT_BILLING[k]])),
|
|
180
|
+
roles: structuredClone(BUILTIN_ROLES),
|
|
181
|
+
harness: {
|
|
182
|
+
codex: { isolated: false },
|
|
183
|
+
"claude-code": { isolated: false },
|
|
184
|
+
opencode: { isolated: false },
|
|
185
|
+
},
|
|
186
|
+
failover: { ...DEFAULT_FAILOVER },
|
|
187
|
+
budget: {},
|
|
188
|
+
timeouts: { idleMin: 15, wallMin: 90 },
|
|
189
|
+
preflight: { confirm: false },
|
|
190
|
+
lock: { heavy: "cpus/2" },
|
|
191
|
+
notify: [...NOTIFY],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Spec §7.1: every field a document leaves out takes its default; a missing role takes the built-in one. */
|
|
196
|
+
export function resolveProfile(doc: ProfileDoc, name: string): Profile {
|
|
197
|
+
const roles = {} as Record<Role, RoleConfig>;
|
|
198
|
+
for (const role of ROLES) {
|
|
199
|
+
const d = doc.roles?.[role];
|
|
200
|
+
const b = BUILTIN_ROLES[role];
|
|
201
|
+
// a role that lists its own rungs never inherits the built-in default rung, which may not be among them
|
|
202
|
+
const defaultRung = d?.defaultRung ?? (d?.rungs === undefined ? b.defaultRung : undefined);
|
|
203
|
+
roles[role] = {
|
|
204
|
+
enabled: d?.enabled ?? b.enabled,
|
|
205
|
+
access:
|
|
206
|
+
d?.access === undefined
|
|
207
|
+
? DEFAULT_ACCESS[role]
|
|
208
|
+
: known(ACCESS, d.access)
|
|
209
|
+
? d.access
|
|
210
|
+
: STORED_FALLBACK.access,
|
|
211
|
+
rungs: [...(d?.rungs ?? b.rungs)],
|
|
212
|
+
...(defaultRung ? { defaultRung } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const harness: Profile["harness"] = {};
|
|
216
|
+
for (const k of new Set([...HARNESS_KEYS, ...Object.keys(doc.harness ?? {})]))
|
|
217
|
+
harness[k] = { isolated: doc.harness?.[k]?.isolated ?? false };
|
|
218
|
+
const budget: Budget = {};
|
|
219
|
+
for (const k of ["minutes", "tokens", "usd"] as const) {
|
|
220
|
+
const v = doc.budget?.[k];
|
|
221
|
+
if (v !== undefined) budget[k] = v;
|
|
222
|
+
}
|
|
223
|
+
const billing: Record<string, BillingMode> = { ...DEFAULT_BILLING };
|
|
224
|
+
for (const [k, v] of Object.entries(doc.billing ?? {}))
|
|
225
|
+
billing[k] = known(BILLING_MODES, v) ? v : STORED_FALLBACK.billing(k);
|
|
226
|
+
const use = doc.jev?.use;
|
|
227
|
+
return {
|
|
228
|
+
name,
|
|
229
|
+
objective: known(OBJECTIVES, doc.objective) ? doc.objective : STORED_FALLBACK.objective,
|
|
230
|
+
jev: { use: use === undefined ? "auto" : known(JEV_USES, use) ? use : STORED_FALLBACK.jevUse },
|
|
231
|
+
billing,
|
|
232
|
+
roles,
|
|
233
|
+
harness,
|
|
234
|
+
failover: { ...doc.failover },
|
|
235
|
+
budget,
|
|
236
|
+
timeouts: { idleMin: doc.timeouts?.idleMin ?? 15, wallMin: doc.timeouts?.wallMin ?? 90 },
|
|
237
|
+
preflight: { confirm: doc.preflight?.confirm ?? false },
|
|
238
|
+
lock: { heavy: doc.lock?.heavy ?? "cpus/2" },
|
|
239
|
+
notify: doc.notify === undefined ? [...NOTIFY] : doc.notify.filter((n) => known(NOTIFY, n)),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Spec §7.3: `catherd-<profile>-<role>-<model slug>-<effort>`, the native subagent for a `claude:` rung. */
|
|
244
|
+
export function agentName(profile: string, role: Role, rung: string): string {
|
|
245
|
+
const r = parseRung(rung);
|
|
246
|
+
return `catherd-${profile}-${role}-${slug(r.model)}-${slug(r.effort)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Patches: what profile_set and `catherd profile set` accept. Strict at every level, so a misspelt key is
|
|
250
|
+
// refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry.
|
|
251
|
+
const RolePatchSchema = z
|
|
252
|
+
.strictObject({
|
|
253
|
+
enabled: z.boolean(),
|
|
254
|
+
access: z.enum(ACCESS),
|
|
255
|
+
rungs: z.array(RungSchema),
|
|
256
|
+
defaultRung: RungSchema.nullable(),
|
|
257
|
+
})
|
|
258
|
+
.partial();
|
|
259
|
+
|
|
260
|
+
export const ProfilePatchSchema = z.strictObject({
|
|
261
|
+
objective: z.enum(["cost", "speed"]).optional(),
|
|
262
|
+
jev: z.strictObject({ use: z.enum(["auto", "off"]) }).optional(),
|
|
263
|
+
billing: z.partialRecord(z.enum(BILLING_KEYS), z.enum(BILLING_MODES).nullable()).optional(),
|
|
264
|
+
roles: z.partialRecord(z.enum(ROLES), RolePatchSchema).optional(),
|
|
265
|
+
harness: z.partialRecord(z.enum(HARNESS_KEYS), z.strictObject({ isolated: z.boolean() })).optional(),
|
|
266
|
+
failover: z.record(RungSchema, RungSchema.nullable()).optional(),
|
|
267
|
+
budget: z
|
|
268
|
+
.strictObject({ minutes: positive.nullable(), tokens: positive.nullable(), usd: positive.nullable() })
|
|
269
|
+
.partial()
|
|
270
|
+
.optional(),
|
|
271
|
+
timeouts: z.strictObject({ idleMin: positive, wallMin: positive }).partial().optional(),
|
|
272
|
+
preflight: z.strictObject({ confirm: z.boolean() }).optional(),
|
|
273
|
+
lock: z.strictObject({ heavy: z.union([z.number().int().positive(), z.literal("cpus/2")]) }).optional(),
|
|
274
|
+
notify: z.array(z.enum(NOTIFY)).optional(),
|
|
275
|
+
});
|
|
276
|
+
export type ProfilePatch = z.infer<typeof ProfilePatchSchema>;
|
|
277
|
+
|
|
278
|
+
/** RFC 7396 merge: objects merge key by key, `null` deletes, anything else (arrays too) replaces. */
|
|
279
|
+
function mergePatch(target: unknown, patch: unknown): unknown {
|
|
280
|
+
if (!isPlain(patch)) return patch;
|
|
281
|
+
const out: Record<string, unknown> = isPlain(target) ? { ...target } : {};
|
|
282
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
283
|
+
if (v === null) delete out[k];
|
|
284
|
+
else out[k] = mergePatch(out[k], v);
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The document with `patch` applied; every field the patch does not name, known or not, is kept. */
|
|
290
|
+
export function applyPatch(doc: ProfileDoc, patch: ProfilePatch): ProfileDoc {
|
|
291
|
+
return ProfileDocSchema.parse(mergePatch(doc, patch));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Map fields whose keys are rungs or billing keys, which may hold dots: the rest of the path is one key. */
|
|
295
|
+
const MAP_FIELDS = new Set(["failover", "billing"]);
|
|
296
|
+
const LIST_FIELDS = (keys: string[]) =>
|
|
297
|
+
(keys[0] === "roles" && keys[2] === "rungs") || (keys[0] === "notify" && keys.length === 1);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `catherd profile set <path> <value>` as a patch. The value is JSON when it parses (numbers, booleans,
|
|
301
|
+
* `null`, arrays), else the plain word; a rung list may also be comma-separated.
|
|
302
|
+
*/
|
|
303
|
+
export function patchAt(path: string, raw: string): ProfilePatch {
|
|
304
|
+
const segs = path.split(".");
|
|
305
|
+
const keys =
|
|
306
|
+
MAP_FIELDS.has(segs[0] ?? "") && segs.length > 1 ? [segs[0] as string, segs.slice(1).join(".")] : segs;
|
|
307
|
+
let value: unknown;
|
|
308
|
+
try {
|
|
309
|
+
value = JSON.parse(raw);
|
|
310
|
+
} catch {
|
|
311
|
+
value = raw;
|
|
312
|
+
}
|
|
313
|
+
if (LIST_FIELDS(keys) && typeof value === "string")
|
|
314
|
+
value = value
|
|
315
|
+
.split(",")
|
|
316
|
+
.map((s) => s.trim())
|
|
317
|
+
.filter(Boolean);
|
|
318
|
+
const patch = keys.reduceRight<unknown>((inner, k) => ({ [k]: inner }), value);
|
|
319
|
+
const r = keys.some((k) => !k) ? null : ProfilePatchSchema.safeParse(patch);
|
|
320
|
+
if (!r?.success)
|
|
321
|
+
throw new CatherdError(
|
|
322
|
+
"E_INPUT_INVALID",
|
|
323
|
+
`cannot set ${path} to ${raw}${r ? `: ${z.prettifyError(r.error).replace(/\n\s*/g, " ")}` : ""}`,
|
|
324
|
+
{
|
|
325
|
+
fix: "catherd profile show --json lists the fields; e.g. catherd profile set roles.worker.access read-only",
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
return r.data;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface Change {
|
|
332
|
+
path: string;
|
|
333
|
+
before: unknown;
|
|
334
|
+
after: unknown;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Leaves by dotted path; an empty object has none, so `budget: {}` → `budget.usd: 5` is one change. */
|
|
338
|
+
function flatten(v: unknown, prefix: string, out: Map<string, unknown>): void {
|
|
339
|
+
if (isPlain(v)) for (const [k, x] of Object.entries(v)) flatten(x, prefix ? `${prefix}.${k}` : k, out);
|
|
340
|
+
else if (v !== undefined) out.set(prefix, v);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Every leaf that differs between two profiles; lists compare whole. */
|
|
344
|
+
export function diffProfiles(a: Profile, b: Profile): Change[] {
|
|
345
|
+
const fa = new Map<string, unknown>();
|
|
346
|
+
const fb = new Map<string, unknown>();
|
|
347
|
+
flatten({ ...a, name: undefined }, "", fa);
|
|
348
|
+
flatten({ ...b, name: undefined }, "", fb);
|
|
349
|
+
const changes: Change[] = [];
|
|
350
|
+
for (const path of [...new Set([...fa.keys(), ...fb.keys()])].sort()) {
|
|
351
|
+
const before = fa.get(path) ?? null;
|
|
352
|
+
const after = fb.get(path) ?? null;
|
|
353
|
+
if (JSON.stringify(before) !== JSON.stringify(after)) changes.push({ path, before, after });
|
|
354
|
+
}
|
|
355
|
+
return changes;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The patch that turns `a` into `b`: the leaves that differ, lists whole, a removed key as `null`. Fields
|
|
360
|
+
* both documents share unchanged, known or not, are left out, so applying it over a newer copy of `a`
|
|
361
|
+
* keeps whatever else changed there. Throws E_INPUT_INVALID when `b` holds what no patch may set.
|
|
362
|
+
*/
|
|
363
|
+
export function patchBetween(a: ProfileDoc, b: ProfileDoc): ProfilePatch {
|
|
364
|
+
const diff = (x: unknown, y: unknown): unknown => {
|
|
365
|
+
if (!isPlain(x) || !isPlain(y)) return y;
|
|
366
|
+
const out: Record<string, unknown> = {};
|
|
367
|
+
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
|
|
368
|
+
if (!(k in y)) out[k] = null;
|
|
369
|
+
else if (JSON.stringify(x[k]) !== JSON.stringify(y[k])) out[k] = diff(x[k], y[k]);
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
};
|
|
373
|
+
const r = ProfilePatchSchema.safeParse(diff(a, b));
|
|
374
|
+
if (!r.success)
|
|
375
|
+
throw new CatherdError(
|
|
376
|
+
"E_INPUT_INVALID",
|
|
377
|
+
`these changes cannot be saved: ${z.prettifyError(r.error).replace(/\n\s*/g, " ")}`,
|
|
378
|
+
{
|
|
379
|
+
fix: "undo the last change, or edit the field with catherd profile set",
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
return r.data;
|
|
383
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const ACCESS = ["read-only", "workspace-write", "full"] as const;
|
|
4
|
+
export type Access = (typeof ACCESS)[number];
|
|
5
|
+
|
|
6
|
+
const RUN_STATUSES = ["ok", "failed", "limit", "cli-too-old", "timeout", "cancelled"] as const;
|
|
7
|
+
export type RunStatus = (typeof RUN_STATUSES)[number];
|
|
8
|
+
|
|
9
|
+
const REPLY_STATUSES = ["complete", "partial", "blocked", "refused"] as const;
|
|
10
|
+
export type ReplyStatus = (typeof REPLY_STATUSES)[number];
|
|
11
|
+
|
|
12
|
+
export interface Tokens {
|
|
13
|
+
input: number;
|
|
14
|
+
cached: number;
|
|
15
|
+
output: number;
|
|
16
|
+
}
|
|
17
|
+
export const ZERO_TOKENS: Readonly<Tokens> = Object.freeze({ input: 0, cached: 0, output: 0 });
|
|
18
|
+
|
|
19
|
+
/** Spec §7: a thread past this many input tokens is spent; its next piece starts fresh. */
|
|
20
|
+
export const THREAD_HEAVY_INPUT = 8_000_000;
|
|
21
|
+
|
|
22
|
+
/** How a supervised worker ended, as the supervisor records it in exit.json. */
|
|
23
|
+
export const EXIT_REASONS = [
|
|
24
|
+
"exited",
|
|
25
|
+
"after-final",
|
|
26
|
+
"idle-timeout",
|
|
27
|
+
"wall-timeout",
|
|
28
|
+
"cancelled",
|
|
29
|
+
"lost",
|
|
30
|
+
] as const;
|
|
31
|
+
export type ExitReason = (typeof EXIT_REASONS)[number];
|
|
32
|
+
export interface ExitInfo {
|
|
33
|
+
code: number | null;
|
|
34
|
+
signal: string | null;
|
|
35
|
+
reason: ExitReason;
|
|
36
|
+
endedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TokensSchema = z.object({ input: z.number(), cached: z.number(), output: z.number() });
|
|
40
|
+
|
|
41
|
+
export const RunRecordSchema = z.looseObject({
|
|
42
|
+
schema: z.literal(1),
|
|
43
|
+
runId: z.string(),
|
|
44
|
+
dispatchId: z.string(),
|
|
45
|
+
name: z.string(),
|
|
46
|
+
role: z.string(),
|
|
47
|
+
lane: z.string().nullable(),
|
|
48
|
+
backend: z.string(),
|
|
49
|
+
rung: z.string(),
|
|
50
|
+
attempt: z.number().int().min(1),
|
|
51
|
+
failoverFrom: z.string().nullable(),
|
|
52
|
+
thread: z.string().nullable(),
|
|
53
|
+
status: z.enum(RUN_STATUSES),
|
|
54
|
+
startedAt: z.string(),
|
|
55
|
+
endedAt: z.string(),
|
|
56
|
+
secs: z.number(),
|
|
57
|
+
exitCode: z.number().nullable(),
|
|
58
|
+
signal: z.string().nullable(),
|
|
59
|
+
cliVersion: z.string().nullable(),
|
|
60
|
+
tokens: TokensSchema,
|
|
61
|
+
costUsd: z.number().nullable(),
|
|
62
|
+
changedOwned: z.array(z.string()),
|
|
63
|
+
violations: z.array(z.string()),
|
|
64
|
+
/** true when git failed at finalize: changedOwned and violations are then unknown, not empty */
|
|
65
|
+
gitUnavailable: z.boolean().optional(),
|
|
66
|
+
replyStatus: z.enum(REPLY_STATUSES).nullable(),
|
|
67
|
+
replyWhy: z.string().nullable(),
|
|
68
|
+
threadHeavy: z.boolean(),
|
|
69
|
+
access: z.enum(ACCESS),
|
|
70
|
+
isolated: z.boolean(),
|
|
71
|
+
images: z.array(z.string()),
|
|
72
|
+
error: z.object({ code: z.string(), message: z.string() }).nullable(),
|
|
73
|
+
replyPath: z.string(),
|
|
74
|
+
});
|
|
75
|
+
export type RunRecord = z.infer<typeof RunRecordSchema>;
|
|
76
|
+
|
|
77
|
+
const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused)\s*(?:—|–|-)\s*(.*)$/;
|
|
78
|
+
|
|
79
|
+
/** The role's claim about its own work: the reply's last line, `STATUS: <s> — <why>`. */
|
|
80
|
+
export function parseReplyStatus(reply: string): { status: ReplyStatus | null; why: string | null } {
|
|
81
|
+
const last = reply.trimEnd().split("\n").at(-1)?.trim() ?? "";
|
|
82
|
+
const m = STATUS_LINE.exec(last);
|
|
83
|
+
return m ? { status: m[1] as ReplyStatus, why: (m[2] ?? "").trim() } : { status: null, why: null };
|
|
84
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { Access } from "./record.ts";
|
|
2
|
+
import type { Role } from "./roles.ts";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// The role prompts of the native Claude subagents, ported from 0.x (spec D9). Headless backends get the
|
|
5
|
+
// same text from the orchestrator's briefs; only native agents carry it in their agent file.
|
|
6
6
|
|
|
7
7
|
const RUN_FILES =
|
|
8
8
|
"The run folder is outside the project. Read and write it only through the catherd MCP tools read_run_file and write_run_file (in the tool list as mcp__plugin_catherd_catherd__read_run_file and mcp__plugin_catherd_catherd__write_run_file), with the run id the orchestrator gave you. Paths are relative to the run folder.";
|
|
@@ -15,6 +15,8 @@ const architect = [
|
|
|
15
15
|
"",
|
|
16
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. ${RUN_FILES}`,
|
|
17
17
|
"",
|
|
18
|
+
"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
|
+
"",
|
|
18
20
|
"Write plan.md with exactly these parts, in this order:",
|
|
19
21
|
"",
|
|
20
22
|
"1. Decisions D1…Dn: each one is a choice, the alternative you rejected, and one line on why. Cover only choices where two reasonable engineers would differ.",
|
|
@@ -22,20 +24,22 @@ const architect = [
|
|
|
22
24
|
"3. Lanes inside each milestone, M1.L1…: each lane gives",
|
|
23
25
|
" - the files it owns (two lanes of one milestone never share a file);",
|
|
24
26
|
" - what changes, stated as behavior plus the exact signatures and data shapes it introduces;",
|
|
25
|
-
" - its fast check: the
|
|
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 the packages the lane owns.",
|
|
26
28
|
"",
|
|
27
29
|
" Every lane of a milestone runs at the same time, so split for width: more small lanes beat one long one.",
|
|
28
30
|
"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.",
|
|
29
31
|
"5. Edge cases the tests must pin, one line each.",
|
|
30
32
|
"6. Out of scope: what no worker may touch.",
|
|
31
33
|
"",
|
|
32
|
-
"Then write one file per lane, lanes/Mx.Ly.md. It is everything that worker needs and nothing else: the lane's section, the decisions and edge cases it relies on (copied, not referenced), and the out-of-scope lines. A worker reads its lane file, never plan.md. Its first
|
|
34
|
+
"Then write one file per lane, lanes/Mx.Ly.md. It is everything that worker needs and nothing else: the lane's section, the decisions and edge cases it relies on (copied, not referenced), and the out-of-scope lines. A worker reads its lane file, never plan.md. Its first five lines are exactly:",
|
|
33
35
|
"",
|
|
34
36
|
" # Mx.Ly — <one line>",
|
|
35
37
|
" Owns: <repo-relative paths, comma-separated>",
|
|
36
38
|
" Fast check: <command>",
|
|
39
|
+
" Kind: repo_code|terminal|ui|prose|research",
|
|
40
|
+
" Difficulty: copy|build|logic|hard",
|
|
37
41
|
"",
|
|
38
|
-
"catherd reads the Owns: line to keep two running lanes off the same file, and to tell a refusal (owned files unchanged) from work.",
|
|
42
|
+
"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.",
|
|
39
43
|
"",
|
|
40
44
|
"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.",
|
|
41
45
|
"",
|
|
@@ -62,15 +66,16 @@ const verifier = [
|
|
|
62
66
|
"- What you could not check, and why.",
|
|
63
67
|
].join("\n");
|
|
64
68
|
|
|
65
|
-
const worker =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
const worker = (version: string) =>
|
|
70
|
+
[
|
|
71
|
+
"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.",
|
|
72
|
+
"",
|
|
73
|
+
`Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
|
|
74
|
+
"",
|
|
75
|
+
`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.`,
|
|
76
|
+
"",
|
|
77
|
+
REPLY,
|
|
78
|
+
].join("\n");
|
|
74
79
|
|
|
75
80
|
const reviewer = [
|
|
76
81
|
"You review a milestone's diff that you did not write. The brief gives the acceptance lines and the changed files; read new files in full. Change nothing in the project.",
|
|
@@ -113,18 +118,29 @@ const writer = [
|
|
|
113
118
|
const researcher = [
|
|
114
119
|
"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.",
|
|
115
120
|
"",
|
|
116
|
-
`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; 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. ${RUN_FILES}`,
|
|
121
|
+
`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. ${RUN_FILES}`,
|
|
117
122
|
"",
|
|
118
123
|
"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>",
|
|
119
124
|
].join("\n");
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
architect:
|
|
123
|
-
verifier:
|
|
124
|
-
worker
|
|
125
|
-
reviewer:
|
|
126
|
-
"ui-reviewer":
|
|
127
|
-
artist:
|
|
128
|
-
writer:
|
|
129
|
-
researcher:
|
|
126
|
+
const BODIES: Record<Role, (version: string) => string> = {
|
|
127
|
+
architect: () => architect,
|
|
128
|
+
verifier: () => verifier,
|
|
129
|
+
worker,
|
|
130
|
+
reviewer: () => reviewer,
|
|
131
|
+
"ui-reviewer": () => uiReviewer,
|
|
132
|
+
artist: () => artist,
|
|
133
|
+
writer: () => writer,
|
|
134
|
+
researcher: () => researcher,
|
|
130
135
|
};
|
|
136
|
+
|
|
137
|
+
/** The role's prompt; the worker's names the catherd version whose `lock` it must use. */
|
|
138
|
+
export const rolePrompt = (role: Role, version: string): string => BODIES[role](version);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Spec D10 for native subagents, whose only lever is the agent file's tool list: read-only drops the
|
|
142
|
+
* editing tools (its Bash stays, for inspection, so enforcement is advisory); every role drops Agent,
|
|
143
|
+
* so a role never spawns its own subagents.
|
|
144
|
+
*/
|
|
145
|
+
export const nativeDisallowedTools = (access: Access): string[] =>
|
|
146
|
+
access === "read-only" ? ["Write", "Edit", "NotebookEdit", "Agent"] : ["Agent"];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Access } from "./record.ts";
|
|
2
|
+
|
|
3
|
+
export const ROLES = [
|
|
4
|
+
"architect",
|
|
5
|
+
"verifier",
|
|
6
|
+
"worker",
|
|
7
|
+
"reviewer",
|
|
8
|
+
"ui-reviewer",
|
|
9
|
+
"artist",
|
|
10
|
+
"writer",
|
|
11
|
+
"researcher",
|
|
12
|
+
] as const;
|
|
13
|
+
export type Role = (typeof ROLES)[number];
|
|
14
|
+
|
|
15
|
+
/** Spec §7.1: the access mode a role runs with unless its profile says otherwise. */
|
|
16
|
+
export const DEFAULT_ACCESS: Record<Role, Access> = {
|
|
17
|
+
architect: "read-only",
|
|
18
|
+
reviewer: "read-only",
|
|
19
|
+
researcher: "read-only",
|
|
20
|
+
worker: "workspace-write",
|
|
21
|
+
writer: "workspace-write",
|
|
22
|
+
artist: "workspace-write",
|
|
23
|
+
verifier: "full",
|
|
24
|
+
"ui-reviewer": "full",
|
|
25
|
+
};
|