catherd-cli 1.0.0 → 1.1.1
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 +66 -42
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +112 -95
- package/plugin/skills/catherd-setup/SKILL.md +3 -3
- package/src/adapters/access.ts +118 -0
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +95 -1
- package/src/adapters/codex/index.ts +78 -23
- package/src/adapters/opencode/index.ts +17 -0
- package/src/domain/errors.ts +2 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/lane.ts +23 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +43 -9
- package/src/domain/profile.ts +12 -6
- package/src/domain/record.ts +2 -0
- package/src/domain/role-prompts.ts +33 -1
- package/src/domain/state.ts +18 -2
- package/src/entry/deps.ts +2 -1
- package/src/entry/doctor-command.ts +7 -1
- package/src/entry/glyphs.ts +2 -1
- package/src/entry/init-command.ts +41 -1
- package/src/entry/mcp/command.ts +5 -1
- package/src/entry/mcp/dispatch-tools.ts +10 -40
- package/src/entry/mcp/handshake.ts +19 -9
- package/src/entry/mcp/lane-tools.ts +7 -6
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/run-tools.ts +4 -3
- package/src/entry/mcp/server.ts +17 -1
- package/src/entry/mcp/setup-tools.ts +1 -1
- package/src/entry/profile-command.ts +3 -5
- package/src/entry/runs-command.ts +64 -21
- package/src/entry/tui/commands.ts +1 -1
- package/src/entry/tui/effects.ts +98 -56
- package/src/entry/tui/fixtures.ts +352 -85
- package/src/entry/tui/profile-edits.ts +2 -2
- package/src/entry/tui/profile-tree.ts +2 -1
- package/src/entry/tui/providers/data.tsx +35 -8
- package/src/entry/tui/run.tsx +11 -1
- package/src/entry/tui/state.ts +26 -6
- package/src/entry/tui/stories.ts +36 -4
- package/src/entry/tui/text.ts +12 -0
- package/src/entry/tui/theme.ts +1 -0
- package/src/entry/tui/views/runs.tsx +409 -117
- package/src/entry/tui/views/status.tsx +5 -2
- package/src/entry/tui/widgets/list.tsx +4 -3
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +14 -6
- package/src/infra/env.ts +26 -2
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/supervisor.ts +33 -3
- package/src/services/admission.ts +38 -12
- package/src/services/dispatch-service.ts +338 -197
- package/src/services/dispatches.ts +30 -1
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +3 -18
- package/src/services/doctor-checks.ts +46 -1
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +59 -79
- package/src/services/finalize.ts +43 -25
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/lane-service.ts +150 -8
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +7 -4
- package/src/services/preflight.ts +17 -3
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +40 -11
- package/src/services/reentry.ts +22 -0
- package/src/services/run-service.ts +42 -9
- package/src/services/run-store.ts +18 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/state.ts +10 -3
- package/src/services/summary.ts +14 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
billingKeyOf,
|
|
3
2
|
type Catalog,
|
|
4
3
|
capableFor,
|
|
5
4
|
effortOffered,
|
|
@@ -8,12 +7,22 @@ import {
|
|
|
8
7
|
rungInfo,
|
|
9
8
|
scoresOf,
|
|
10
9
|
} from "./catalog.ts";
|
|
11
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
catalogRungs,
|
|
12
|
+
claudeBilled,
|
|
13
|
+
downgradeDims,
|
|
14
|
+
ladderDropDims,
|
|
15
|
+
quotaOf,
|
|
16
|
+
rankStandIns,
|
|
17
|
+
} from "./failover.ts";
|
|
18
|
+
import { tryParseRung } from "./ids.ts";
|
|
12
19
|
import { DIFFICULTIES, KINDS } from "./lane.ts";
|
|
13
20
|
import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
|
|
14
21
|
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
15
22
|
import { candidates, clearsBar, type RoutingProfile } from "./select.ts";
|
|
16
23
|
|
|
24
|
+
export { quotaOf };
|
|
25
|
+
|
|
17
26
|
/** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
|
|
18
27
|
export interface Issue {
|
|
19
28
|
path: string;
|
|
@@ -35,12 +44,6 @@ export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
|
|
|
35
44
|
role: p.roles[role],
|
|
36
45
|
});
|
|
37
46
|
|
|
38
|
-
/**
|
|
39
|
-
* Spec §7.1 and §4.5: a stand-in on the same quota would be out of quota too. The billing key names the
|
|
40
|
-
* quota, except that the native `claude` path and headless `claude-code` both draw on the Claude plan.
|
|
41
|
-
*/
|
|
42
|
-
export const quotaOf = (r: Rung): string => (billingKeyOf(r) === "claude" ? "claude-code" : billingKeyOf(r));
|
|
43
|
-
|
|
44
47
|
/** Whether a rung's scores are catherd's guess: borrowed through a treat-like, or only `inferred` ones. */
|
|
45
48
|
export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean; via: string | null } {
|
|
46
49
|
const s = scoresOf(c, info.canonical);
|
|
@@ -54,7 +57,8 @@ export function inferredScores(c: Catalog, info: RungInfo): { inferred: boolean;
|
|
|
54
57
|
* treat-like; a failover stand-in unscored or on the same quota; a rung whose backend catherd cannot run
|
|
55
58
|
* (`backends` lists those it can, `claude` included). Everything else worth knowing is a warning: an access
|
|
56
59
|
* mode other than the role's default, an effort or model the last listing does not offer, a stand-in
|
|
57
|
-
* that never runs
|
|
60
|
+
* that never runs, and (spec 1.1 §11) a stand-in that downgrades its rung, one that spends Claude quota
|
|
61
|
+
* while another plan could stand in, and a ladder that goes down. With the stored `doc` `p` came from, a value this catherd does not know is also a
|
|
58
62
|
* warning, one that says how the value is read.
|
|
59
63
|
*/
|
|
60
64
|
export function validateProfile(
|
|
@@ -131,6 +135,16 @@ export function validateProfile(
|
|
|
131
135
|
if (!scoresOf(c, info.canonical))
|
|
132
136
|
errors.push({ path: `${at}.rungs`, message: `${rung} is unscored`, fix: TREAT_LIKE_FIX(rung) });
|
|
133
137
|
}
|
|
138
|
+
rc.rungs.forEach((rung, i) => {
|
|
139
|
+
const prev = rc.rungs[i - 1];
|
|
140
|
+
const down = prev === undefined ? [] : ladderDropDims(c, prev, rung);
|
|
141
|
+
if (down.length)
|
|
142
|
+
warnings.push({
|
|
143
|
+
path: `${at}.rungs`,
|
|
144
|
+
message: `the ladder goes down at ${rung}: it scores below ${prev} on ${down.join(", ")}`,
|
|
145
|
+
fix: `order ${at}.rungs weakest first`,
|
|
146
|
+
});
|
|
147
|
+
});
|
|
134
148
|
if (rc.defaultRung !== undefined && !rc.rungs.includes(rc.defaultRung))
|
|
135
149
|
errors.push({
|
|
136
150
|
path: `${at}.defaultRung`,
|
|
@@ -169,6 +183,11 @@ export function validateProfile(
|
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
|
|
186
|
+
// the stand-ins catherd could run here, for the fixes below
|
|
187
|
+
const pool = catalogRungs(c).filter((x) => {
|
|
188
|
+
const r = tryParseRung(x);
|
|
189
|
+
return r !== null && backends.includes(r.backend);
|
|
190
|
+
});
|
|
172
191
|
for (const [from, to] of Object.entries(p.failover)) {
|
|
173
192
|
const at = `failover.${from}`;
|
|
174
193
|
const a = tryParseRung(from);
|
|
@@ -193,6 +212,21 @@ export function validateProfile(
|
|
|
193
212
|
message: `stand-in ${to} draws on the same quota as ${from}, which is out when ${from} hits its limit`,
|
|
194
213
|
fix: "name a stand-in on another backend or plan",
|
|
195
214
|
});
|
|
215
|
+
const ranked = rankStandIns(c, p.billing, from, pool);
|
|
216
|
+
const down = downgradeDims(c, from, to);
|
|
217
|
+
if (down.length)
|
|
218
|
+
warnings.push({
|
|
219
|
+
path: at,
|
|
220
|
+
message: `downgrade: ${to} stands in for ${from}, scoring below it on ${down.join(", ")}`,
|
|
221
|
+
fix: `catherd profile set failover.${from} ${ranked[0] ?? "null"}`,
|
|
222
|
+
});
|
|
223
|
+
const other = ranked.find((x) => !claudeBilled(x));
|
|
224
|
+
if (claudeBilled(to) && other)
|
|
225
|
+
warnings.push({
|
|
226
|
+
path: at,
|
|
227
|
+
message: `stand-in ${to} spends Claude quota, while ${other} could stand in on another plan`,
|
|
228
|
+
fix: `catherd profile set failover.${from} ${other}`,
|
|
229
|
+
});
|
|
196
230
|
if (b.backend === "claude")
|
|
197
231
|
warnings.push({
|
|
198
232
|
path: at,
|
package/src/domain/profile.ts
CHANGED
|
@@ -43,6 +43,7 @@ const RoleDocSchema = z.looseObject({
|
|
|
43
43
|
access: z.string().optional(),
|
|
44
44
|
rungs: z.array(z.string()).optional(),
|
|
45
45
|
defaultRung: z.string().optional(),
|
|
46
|
+
network: z.boolean().optional(),
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
export const ProfileDocSchema = z.looseObject({
|
|
@@ -113,6 +114,8 @@ export type RoleConfig = {
|
|
|
113
114
|
/** in ladder order, each `backend:model#effort`; `claude:` runs as a native subagent, `claude-code:` headless */
|
|
114
115
|
rungs: string[];
|
|
115
116
|
defaultRung?: string;
|
|
117
|
+
/** spec §5: false drops a workspace-write role's network, loopback and Docker grants; absent means granted */
|
|
118
|
+
network?: false;
|
|
116
119
|
};
|
|
117
120
|
|
|
118
121
|
/** A profile with every default filled in: what the run engine, the CLI and the agent files read. */
|
|
@@ -155,15 +158,15 @@ export const BUILTIN_ROLES: Record<Role, RoleConfig> = {
|
|
|
155
158
|
};
|
|
156
159
|
|
|
157
160
|
/**
|
|
158
|
-
* Spec §
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
+
* Spec 1.1 §11: each shipped worker rung fails over to the best stand-in the shipped catalog offers
|
|
162
|
+
* (`rankStandIns` over `catalogRungs`, pinned by test/domain/failover.test.ts): on another quota, paid
|
|
163
|
+
* from a plan, and no downgrade on the dims the rung's bars use. Go serves GPT-6 Luna itself; Kimi K3
|
|
164
|
+
* borrows Sol medium's scores (a shipped treat-like). Sol high and xhigh have no such stand-in on the
|
|
165
|
+
* owner's plans, so they have none: a limit on them pauses the lane instead of dropping it a tier.
|
|
161
166
|
*/
|
|
162
167
|
export const DEFAULT_FAILOVER: Record<string, string> = {
|
|
163
168
|
[LUNA_HIGH]: "opencode:opencode-go/gpt-6-luna#high",
|
|
164
169
|
[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
170
|
};
|
|
168
171
|
|
|
169
172
|
/** The five billing keys spec §7.1 writes out; cursor and grok arrive with their backends. */
|
|
@@ -210,6 +213,7 @@ export function resolveProfile(doc: ProfileDoc, name: string): Profile {
|
|
|
210
213
|
: STORED_FALLBACK.access,
|
|
211
214
|
rungs: [...(d?.rungs ?? b.rungs)],
|
|
212
215
|
...(defaultRung ? { defaultRung } : {}),
|
|
216
|
+
...(d?.network === false ? { network: false as const } : {}),
|
|
213
217
|
};
|
|
214
218
|
}
|
|
215
219
|
const harness: Profile["harness"] = {};
|
|
@@ -247,13 +251,15 @@ export function agentName(profile: string, role: Role, rung: string): string {
|
|
|
247
251
|
}
|
|
248
252
|
|
|
249
253
|
// 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
|
|
254
|
+
// refused with E_INPUT_INVALID instead of dropped (plan-2 review m9). `null` removes a map entry or a nullable
|
|
255
|
+
// field (a role's `network` among them: absent means granted).
|
|
251
256
|
const RolePatchSchema = z
|
|
252
257
|
.strictObject({
|
|
253
258
|
enabled: z.boolean(),
|
|
254
259
|
access: z.enum(ACCESS),
|
|
255
260
|
rungs: z.array(RungSchema),
|
|
256
261
|
defaultRung: RungSchema.nullable(),
|
|
262
|
+
network: z.boolean().nullable(),
|
|
257
263
|
})
|
|
258
264
|
.partial();
|
|
259
265
|
|
package/src/domain/record.ts
CHANGED
|
@@ -71,6 +71,8 @@ export const RunRecordSchema = z.looseObject({
|
|
|
71
71
|
images: z.array(z.string()),
|
|
72
72
|
error: z.object({ code: z.string(), message: z.string() }).nullable(),
|
|
73
73
|
replyPath: z.string(),
|
|
74
|
+
/** the Claude Code session that dispatched it, copied from admit.json (spec §3.3); absent before 1.1 */
|
|
75
|
+
sessionId: z.string().optional(),
|
|
74
76
|
});
|
|
75
77
|
export type RunRecord = z.infer<typeof RunRecordSchema>;
|
|
76
78
|
|
|
@@ -53,7 +53,11 @@ const architect = [
|
|
|
53
53
|
const verifier = [
|
|
54
54
|
"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
55
|
"",
|
|
56
|
-
"1. Run the check command once. Report its exit code and the failing lines.",
|
|
56
|
+
"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
|
+
" - Before each item, call the catherd MCP tool gate_check (mcp__plugin_catherd_catherd__gate_check) 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.",
|
|
58
|
+
" - After an item passes, call gate_pass (mcp__plugin_catherd_catherd__gate_pass) with the same item, command and paths, and the evidence.",
|
|
59
|
+
" - Run independent items side by side, each heavy one wrapped in catherd lock, which queues them within the machine's slots.",
|
|
60
|
+
" - Build each commit's images once, and reuse them for the boot check and the acceptance suite.",
|
|
57
61
|
"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.",
|
|
58
62
|
"3. Read the diff (git diff plus untracked files) for bugs the acceptance lines miss: wrong edge behavior, dead code, leftovers.",
|
|
59
63
|
"",
|
|
@@ -62,6 +66,7 @@ const verifier = [
|
|
|
62
66
|
"Return, in this order:",
|
|
63
67
|
"- VERDICT: PASS or VERDICT: FAIL on the first line.",
|
|
64
68
|
"- One line per acceptance line: A<n> PASS|FAIL, the command you ran and the decisive output.",
|
|
69
|
+
"- One line per gate item: PASS|FAIL, or carried over from <commit>.",
|
|
65
70
|
"- Bugs outside the acceptance lines: file:line, what happens, the input that triggers it.",
|
|
66
71
|
"- What you could not check, and why.",
|
|
67
72
|
].join("\n");
|
|
@@ -134,6 +139,33 @@ const BODIES: Record<Role, (version: string) => string> = {
|
|
|
134
139
|
researcher: () => researcher,
|
|
135
140
|
};
|
|
136
141
|
|
|
142
|
+
const STATUS_LINE =
|
|
143
|
+
"The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Spec 1.1 §6: the reply contract `dispatch` appends to every brief it writes, the tail of the role's
|
|
147
|
+
* prompt: reply length and the STATUS line. The architect and the verifier keep their own reply shapes.
|
|
148
|
+
*/
|
|
149
|
+
const CONTRACTS: Record<Role, string> = {
|
|
150
|
+
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: REPLY,
|
|
153
|
+
reviewer: REPLY,
|
|
154
|
+
"ui-reviewer": REPLY,
|
|
155
|
+
artist: REPLY,
|
|
156
|
+
writer: REPLY,
|
|
157
|
+
researcher: `For a single question, reply in at most 15 lines. ${STATUS_LINE}`,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const replyContract = (role: Role): string => CONTRACTS[role];
|
|
161
|
+
|
|
162
|
+
/** `brief` with the role's reply contract as its last paragraph, once: a brief that already ends with it is kept. */
|
|
163
|
+
export function withReplyContract(role: Role, brief: string): string {
|
|
164
|
+
const contract = CONTRACTS[role];
|
|
165
|
+
const body = brief.trimEnd();
|
|
166
|
+
return body.endsWith(contract) ? `${body}\n` : `${body}\n\n${contract}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
137
169
|
/** The role's prompt; the worker's names the catherd version whose `lock` it must use. */
|
|
138
170
|
export const rolePrompt = (role: Role, version: string): string => BODIES[role](version);
|
|
139
171
|
|
package/src/domain/state.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
const PARKED = /^parked: [^;]*; /;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spec 1.1 §8: `next` with the parked milestones in front of it, once ("parked: M2 waits on the owner;
|
|
5
|
+
* <next>"), whoever wrote the step: a milestone stays parked in state.md until it is answered.
|
|
6
|
+
*/
|
|
7
|
+
export function withParked(next: string, parked: string[]): string {
|
|
8
|
+
const base = next.replace(PARKED, "");
|
|
9
|
+
if (parked.length === 0) return base;
|
|
10
|
+
return `parked: ${parked.join(", ")} ${parked.length > 1 ? "wait" : "waits"} on the owner; ${base}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
export interface StateView {
|
|
2
14
|
title: string;
|
|
3
15
|
head: string;
|
|
@@ -5,9 +17,11 @@ export interface StateView {
|
|
|
5
17
|
running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
|
|
6
18
|
lastCheck: string | null;
|
|
7
19
|
next: string;
|
|
20
|
+
/** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
|
|
21
|
+
protocol: string;
|
|
8
22
|
}
|
|
9
23
|
|
|
10
|
-
/** state.md: enough for a fresh session to resume from alone; the next step is always the last line. */
|
|
24
|
+
/** state.md: enough for a fresh session to resume from alone; the protocol's next step is always the last line. */
|
|
11
25
|
export function renderState(s: StateView): string {
|
|
12
26
|
const waiting = s.running.map((r) => r.name);
|
|
13
27
|
return [
|
|
@@ -27,7 +41,9 @@ export function renderState(s: StateView): string {
|
|
|
27
41
|
"",
|
|
28
42
|
`Last check: ${s.lastCheck ?? "none"}`,
|
|
29
43
|
"",
|
|
30
|
-
|
|
44
|
+
// 1.1: no tool waits; each running role's record arrives as a catherd message
|
|
45
|
+
`Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,
|
|
46
|
+
`Protocol next: ${s.protocol}`,
|
|
31
47
|
"",
|
|
32
48
|
].join("\n");
|
|
33
49
|
}
|
package/src/entry/deps.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readSessionEnv } from "../infra/claude-session.ts";
|
|
1
2
|
import { VERSION } from "../infra/version.ts";
|
|
2
3
|
import type { Deps } from "../services/ports.ts";
|
|
3
4
|
import { profileService } from "../services/profile-service.ts";
|
|
@@ -10,7 +11,7 @@ export function defaultDeps(): Deps {
|
|
|
10
11
|
routing: routingService(),
|
|
11
12
|
version: VERSION,
|
|
12
13
|
pollMs: 250,
|
|
13
|
-
|
|
14
|
+
session: readSessionEnv(process.env),
|
|
14
15
|
now: Date.now,
|
|
15
16
|
};
|
|
16
17
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { VERSION } from "../infra/version.ts";
|
|
3
3
|
import { type DoctorReport, doctor } from "../services/doctor.ts";
|
|
4
|
+
import { probePush } from "../services/doctor-push.ts";
|
|
4
5
|
import type { Check } from "../services/doctor-checks.ts";
|
|
5
6
|
import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
6
7
|
import { mcpHandshake } from "./mcp/handshake.ts";
|
|
@@ -37,7 +38,12 @@ export const doctorCommand = defineCommand({
|
|
|
37
38
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
38
39
|
},
|
|
39
40
|
async run({ args }) {
|
|
40
|
-
const r = await doctor({
|
|
41
|
+
const r = await doctor({
|
|
42
|
+
bunVersion: Bun.version,
|
|
43
|
+
version: VERSION,
|
|
44
|
+
handshake: () => mcpHandshake(),
|
|
45
|
+
push: () => probePush(),
|
|
46
|
+
});
|
|
41
47
|
if (args.json) printJson(r);
|
|
42
48
|
else for (const l of formatReport(r, args.plain === true)) console.log(l);
|
|
43
49
|
process.exitCode = r.ready ? EXIT.ok : EXIT.notReady;
|
package/src/entry/glyphs.ts
CHANGED
|
@@ -7,6 +7,7 @@ const GLYPHS = {
|
|
|
7
7
|
warn: ["!", "!"],
|
|
8
8
|
fail: ["✗", "x"],
|
|
9
9
|
skip: ["-", "-"],
|
|
10
|
+
info: ["i", "i"],
|
|
10
11
|
live: ["●", "*"],
|
|
11
12
|
waiting: ["◌", "."],
|
|
12
13
|
on: ["[x]", "[x]"],
|
|
@@ -30,4 +31,4 @@ export const GLYPH_NAMES = Object.keys(GLYPHS) as Glyph[];
|
|
|
30
31
|
export const glyph = (g: Glyph, plain: boolean): string => GLYPHS[g][plain ? 1 : 0];
|
|
31
32
|
|
|
32
33
|
/** The words state always travels with (spec §9.3: green/amber/red only for state, always with a word). */
|
|
33
|
-
export type State = "ok" | "warn" | "fail" | "skip";
|
|
34
|
+
export type State = "ok" | "warn" | "fail" | "skip" | "info";
|
|
@@ -5,6 +5,8 @@ import { configDir } from "../infra/paths.ts";
|
|
|
5
5
|
import { VERSION } from "../infra/version.ts";
|
|
6
6
|
import { doctor } from "../services/doctor.ts";
|
|
7
7
|
import { jevKey, saveJevKey, testJevKey } from "../services/jev-service.ts";
|
|
8
|
+
import { reinstallCommand } from "../services/doctor-checks.ts";
|
|
9
|
+
import { ensureGlobal, type GlobalInstallDeps, realGlobalInstall } from "../services/global-install.ts";
|
|
8
10
|
import { hasProfileFile, type InitResult, initSetup, moveLegacy } from "../services/setup.ts";
|
|
9
11
|
import { formatRefreshed } from "./catalog-command.ts";
|
|
10
12
|
import { mark as markOf } from "./cli-kit.ts";
|
|
@@ -63,6 +65,38 @@ export async function jevStep(
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Spec 1.1 §12: installs the global CLI at this version (the plugin's launcher then needs no bunx), and
|
|
70
|
+
* says "installing catherd…" before the resolve. It never stops `init`: a failure is a `!` line and its fix.
|
|
71
|
+
*/
|
|
72
|
+
export async function globalStep(
|
|
73
|
+
version: string,
|
|
74
|
+
o: { skip: boolean; deps?: GlobalInstallDeps; plain?: boolean },
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const mark = (state: State) => markOf(state, o.plain === true);
|
|
77
|
+
if (o.skip)
|
|
78
|
+
return console.log(`- catherd: not installed globally (--no-global); the plugin starts it with bunx`);
|
|
79
|
+
const r = await ensureGlobal(version, o.deps ?? realGlobalInstall, () =>
|
|
80
|
+
console.log(`installing catherd… (${reinstallCommand(version)})`),
|
|
81
|
+
);
|
|
82
|
+
if (r.state === "current") return console.log(`${mark("ok")} catherd ${version} is installed globally`);
|
|
83
|
+
if (r.state === "installed")
|
|
84
|
+
return console.log(
|
|
85
|
+
`${mark("ok")} catherd ${version} installed globally; the plugin starts it without bunx`,
|
|
86
|
+
);
|
|
87
|
+
if (r.state === "shadowed") {
|
|
88
|
+
const found = r.onPath === null ? "no catherd is on PATH" : `the catherd first on PATH is ${r.onPath}`;
|
|
89
|
+
console.log(
|
|
90
|
+
`${mark("warn")} catherd ${version} installed globally, but ${found}, so the plugin starts it with bunx`,
|
|
91
|
+
);
|
|
92
|
+
return console.log(
|
|
93
|
+
` fix: put bun's global bin folder (bun pm bin -g) first on PATH, then run catherd init again`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
console.log(`${mark("warn")} could not install catherd globally: ${r.reason}`);
|
|
97
|
+
console.log(` fix: ${reinstallCommand(version)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
/** What happened to the profile: written, kept, or (when the defaults do not validate here) why not. */
|
|
67
101
|
export function profileLines(r: InitResult, plain = false): string[] {
|
|
68
102
|
const mark = (state: State) => markOf(state, plain);
|
|
@@ -90,11 +124,16 @@ export const initCommand = defineCommand({
|
|
|
90
124
|
meta: {
|
|
91
125
|
name: "init",
|
|
92
126
|
description:
|
|
93
|
-
"First run: the Jev key, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
|
|
127
|
+
"First run: the global catherd command, the Jev key, the default profile, its agents, and a readiness report. Piped, it reads the answers from stdin one per line, a line per question even when this machine skips it (the Jev key, the profile, whether to replace it), and waits for stdin to close; --no-input asks nothing",
|
|
94
128
|
},
|
|
95
129
|
args: {
|
|
96
130
|
// citty reads --no-input as input: false, whatever the flag is named; naming it no-input shows it as is
|
|
97
131
|
"no-input": { type: "boolean", description: "ask nothing: keep what exists, else write the defaults" },
|
|
132
|
+
// read as global: false, like no-input above
|
|
133
|
+
"no-global": {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
description: "do not install the global catherd command (bun add -g catherd-cli@<this version>)",
|
|
136
|
+
},
|
|
98
137
|
profile: { type: "string", description: "the profile to set up and make active (default: default)" },
|
|
99
138
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
100
139
|
},
|
|
@@ -107,6 +146,7 @@ export const initCommand = defineCommand({
|
|
|
107
146
|
try {
|
|
108
147
|
if (process.stdout.isTTY) for (const line of welcomeLines(VERSION)) console.log(line);
|
|
109
148
|
console.log(`catherd ${VERSION}: setting up in ${configDir()}`);
|
|
149
|
+
await globalStep(VERSION, { skip: (args as { global?: boolean }).global === false, plain });
|
|
110
150
|
await jevStep(ask, {}, plain);
|
|
111
151
|
if (args.profile !== undefined) ask?.skip?.();
|
|
112
152
|
const name = assertProfileName(
|
package/src/entry/mcp/command.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
+
import { restoreTmpdir } from "../../infra/env.ts";
|
|
2
3
|
import { startMcpServer } from "./server.ts";
|
|
3
4
|
|
|
4
5
|
export const mcpCommand = defineCommand({
|
|
5
6
|
meta: { name: "mcp", description: "Run the catherd MCP server over stdio" },
|
|
6
|
-
run: () =>
|
|
7
|
+
run: () => {
|
|
8
|
+
restoreTmpdir(process.env);
|
|
9
|
+
return startMcpServer();
|
|
10
|
+
},
|
|
7
11
|
});
|
|
@@ -2,37 +2,17 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ID_PATTERN } from "../../domain/ids.ts";
|
|
4
4
|
import { ROLES } from "../../domain/roles.ts";
|
|
5
|
-
import { cancel, dispatch
|
|
5
|
+
import { cancel, dispatch } from "../../services/dispatch-service.ts";
|
|
6
|
+
import { peek } from "../../services/peek.ts";
|
|
6
7
|
import type { Deps } from "../../services/ports.ts";
|
|
7
8
|
import { handle } from "./result.ts";
|
|
8
9
|
|
|
9
|
-
type Notify = (n: {
|
|
10
|
-
method: "notifications/progress";
|
|
11
|
-
params: { progressToken: string | number; progress: number; message: string };
|
|
12
|
-
}) => Promise<void>;
|
|
13
|
-
|
|
14
|
-
/** Progress notifications that can never fail a wait: the client may be gone (audit C8). */
|
|
15
|
-
export function progressTo(token: string | number | undefined, send: Notify): Progress | undefined {
|
|
16
|
-
if (token === undefined) return undefined;
|
|
17
|
-
let n = 0;
|
|
18
|
-
return (message) => {
|
|
19
|
-
try {
|
|
20
|
-
send({
|
|
21
|
-
method: "notifications/progress",
|
|
22
|
-
params: { progressToken: token, progress: ++n, message },
|
|
23
|
-
}).catch(() => {}); // the client is gone: progress is best effort
|
|
24
|
-
} catch {
|
|
25
|
-
// the transport is closed
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
10
|
export function registerDispatchTools(server: McpServer, deps: Deps): void {
|
|
31
11
|
server.registerTool(
|
|
32
12
|
"dispatch",
|
|
33
13
|
{
|
|
34
14
|
description:
|
|
35
|
-
"Start one role on a process backend (codex, claude-code, opencode) and return at launch, in about a second, with { dispatched: { name, role, rung, dispatchId, admittedAt }, hints }; the role runs on, and
|
|
15
|
+
"Start one role on a process backend (codex, claude-code, opencode) and return at launch, in about a second, with { dispatched: { name, role, rung, dispatchId, admittedAt }, hints }; the role runs on. When it finishes, catherd sends this session a <cross-session-message from-name=\"catherd\"> naming the run, the role and its status, and result(run, name) reads the record. Dispatch every independent role one after another, then end your turn. The brief is the text itself; dispatch appends the role's reply contract (reply length and the STATUS line), so do not write it. With lane, the lane file's Owns: paths guard against overlapping lanes, and a lane not yet routed is routed first (a rung off its routed ladder starts at the routed rung, with a hint). thread resumes that role's thread for its own fix round. Refused with a structured error (E_ADMIT_*, E_RUN_BUDGET, E_BACKEND_*) before anything starts. Call it from the main thread.",
|
|
36
16
|
inputSchema: {
|
|
37
17
|
run: z.string(),
|
|
38
18
|
role: z.enum(ROLES),
|
|
@@ -48,35 +28,25 @@ export function registerDispatchTools(server: McpServer, deps: Deps): void {
|
|
|
48
28
|
);
|
|
49
29
|
|
|
50
30
|
server.registerTool(
|
|
51
|
-
"
|
|
31
|
+
"peek",
|
|
52
32
|
{
|
|
53
33
|
description:
|
|
54
|
-
"
|
|
34
|
+
"A look at the runs, without waiting: with run, that run (and this session becomes its owner); without, every run this session owns, else the newest. Per run, first the open owner questions, which are the owner's questions: push them to the owner, relay the owner's answer with answer(run, milestone, answer), and go on with the work that does not depend on them. Then each live role with its rung, seconds since it started and its last event (the last command, file edit or message line); every finished record not yet read, as the first line of catherd's message; the latest native Claude run; the run's next step; protocol, the milestone loop's next step and its six-line checklist; and verifier, the verifier's latest gate step. name narrows it to one role. It never marks a record read: result(run, name) does. Call it when the user asks how it is going, when a decision needs the other roles' state, or once after run_start on a resumed run; never in a loop.",
|
|
55
35
|
inputSchema: {
|
|
56
|
-
run: z.string(),
|
|
57
|
-
|
|
58
|
-
all: z.boolean().optional(),
|
|
36
|
+
run: z.string().optional(),
|
|
37
|
+
name: z.string().regex(ID_PATTERN).optional(),
|
|
59
38
|
},
|
|
60
39
|
},
|
|
61
|
-
(a
|
|
62
|
-
handle(() =>
|
|
63
|
-
wait(
|
|
64
|
-
deps,
|
|
65
|
-
a,
|
|
66
|
-
progressTo(extra._meta?.progressToken, (n) => extra.sendNotification(n)),
|
|
67
|
-
// a wait its caller cancelled collects nothing: the record would go to no one
|
|
68
|
-
extra.signal,
|
|
69
|
-
),
|
|
70
|
-
),
|
|
40
|
+
(a) => handle(() => peek(deps, a)),
|
|
71
41
|
);
|
|
72
42
|
|
|
73
43
|
server.registerTool(
|
|
74
44
|
"cancel",
|
|
75
45
|
{
|
|
76
46
|
description:
|
|
77
|
-
"Stop a live dispatch (interrupt, then SIGTERM, then SIGKILL) and return its record, marked cancelled, and hints.",
|
|
47
|
+
"Stop a live dispatch (interrupt, then SIGTERM, then SIGKILL) and return its record, marked cancelled and read, and hints.",
|
|
78
48
|
inputSchema: { run: z.string(), name: z.string().regex(ID_PATTERN) },
|
|
79
49
|
},
|
|
80
|
-
(a) => handle(() => cancel(deps, a.run, a.name)),
|
|
50
|
+
(a) => handle(() => cancel(deps, a.run, a.name, { read: true })),
|
|
81
51
|
);
|
|
82
52
|
}
|
|
@@ -6,27 +6,37 @@ import { scrubSecrets } from "../../infra/env.ts";
|
|
|
6
6
|
import { VERSION } from "../../infra/version.ts";
|
|
7
7
|
import type { Handshake } from "../../services/doctor.ts";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
/** The plugin's MCP launcher, shipped in the package beside src/ (spec 1.1 §12). */
|
|
10
|
+
export const LAUNCHER = fileURLToPath(new URL("../../../plugin/bin/catherd-mcp", import.meta.url));
|
|
11
|
+
/** A cold `bunx catherd-cli@<version>` resolve took about 30 s in the 1.0.0 fresh install. */
|
|
12
|
+
const TIMEOUT_MS = 60_000;
|
|
13
|
+
const STDERR_TAIL = 4_000;
|
|
11
14
|
|
|
12
15
|
/** The env the doctor's MCP server starts with: catherd's own secrets scrubbed, as for every process it starts. */
|
|
13
16
|
export const handshakeEnv = (
|
|
14
17
|
base: Record<string, string | undefined> = process.env,
|
|
15
18
|
): Record<string, string> => scrubSecrets(base);
|
|
16
19
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Spec §10.3 and 1.1 §12: starts the MCP server the way the plugin does, `sh <launcher>` over stdio, and
|
|
22
|
+
* asks it for tools/list. What it writes to stderr comes back too, so doctor can say what broke.
|
|
23
|
+
*/
|
|
24
|
+
export async function mcpHandshake(o: { launcher?: string } = {}): Promise<Handshake> {
|
|
19
25
|
const client = new Client({ name: "catherd-doctor", version: VERSION });
|
|
20
26
|
const transport = new StdioClientTransport({
|
|
21
|
-
command:
|
|
22
|
-
args: [
|
|
27
|
+
command: "sh",
|
|
28
|
+
args: [o.launcher ?? LAUNCHER],
|
|
23
29
|
env: handshakeEnv(),
|
|
24
|
-
stderr: "
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
});
|
|
32
|
+
let stderr = "";
|
|
33
|
+
transport.stderr?.on("data", (b: Buffer) => {
|
|
34
|
+
stderr = (stderr + b.toString()).slice(-STDERR_TAIL);
|
|
25
35
|
});
|
|
26
36
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
27
37
|
const late = new Promise<Handshake>((resolve) => {
|
|
28
38
|
timer = setTimeout(
|
|
29
|
-
() => resolve({ ok: false, tools: [], error: `no answer within ${TIMEOUT_MS / 1000} s
|
|
39
|
+
() => resolve({ ok: false, tools: [], error: `no answer within ${TIMEOUT_MS / 1000} s`, stderr }),
|
|
30
40
|
TIMEOUT_MS,
|
|
31
41
|
);
|
|
32
42
|
});
|
|
@@ -40,7 +50,7 @@ export async function mcpHandshake(): Promise<Handshake> {
|
|
|
40
50
|
late,
|
|
41
51
|
]);
|
|
42
52
|
} catch (e) {
|
|
43
|
-
return { ok: false, tools: [], error: errorMessage(e) };
|
|
53
|
+
return { ok: false, tools: [], error: errorMessage(e), stderr };
|
|
44
54
|
} finally {
|
|
45
55
|
clearTimeout(timer);
|
|
46
56
|
await client.close().catch(() => {}); // the answer (or the failure) is already in hand
|
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { ID_PATTERN } from "../../domain/ids.ts";
|
|
4
4
|
import { ROLES } from "../../domain/roles.ts";
|
|
5
5
|
import { CLIMB_REASONS } from "../../domain/route.ts";
|
|
6
|
-
import { ask, climb, land, route } from "../../services/lane-service.ts";
|
|
6
|
+
import { ask, climb, LAND_SKIPS, land, route } from "../../services/lane-service.ts";
|
|
7
7
|
import type { Deps } from "../../services/ports.ts";
|
|
8
8
|
import { preflight } from "../../services/preflight.ts";
|
|
9
9
|
import { handle } from "./result.ts";
|
|
@@ -13,7 +13,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
|
|
|
13
13
|
"route",
|
|
14
14
|
{
|
|
15
15
|
description:
|
|
16
|
-
"The rung for a lane (from Jev, else the lane file's Kind/Difficulty lines, else the profile default) or, without a lane file, a role's default rung, with the ladder above it. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as.",
|
|
16
|
+
"The rung for a lane (from Jev, else the lane file's Kind/Difficulty lines, else the profile default) or, without a lane file, a role's default rung, with the ladder above it. Rungs are backend:model#effort; a claude: rung comes with the agent to run it as. A lane whose Kind: or Difficulty: the catalog does not know is refused with E_LANE_INVALID.",
|
|
17
17
|
inputSchema: {
|
|
18
18
|
run: z.string(),
|
|
19
19
|
lane_file: z.string().optional(),
|
|
@@ -27,7 +27,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
|
|
|
27
27
|
"preflight",
|
|
28
28
|
{
|
|
29
29
|
description:
|
|
30
|
-
"Run each lane's fast check once, on the base tree, behind the heavy-command lock, with a 120 s timeout. Each lane is pass, fails-as-expected, skipped (it checks a file the lane creates) or cannot-start; only cannot-start blocks. When the profile asks for confirmation, the first call returns the commands to show the user; call again with confirmed: true.",
|
|
30
|
+
"Run each lane's fast check once, on the base tree, behind the heavy-command lock, with a 120 s timeout. Each lane is pass, fails-as-expected, skipped (it checks a file the lane creates) or cannot-start; only cannot-start blocks. When the profile asks for confirmation, the first call returns the commands to show the user; call again with confirmed: true. Refused with E_LANE_INVALID, running nothing, while any lane's Kind: or Difficulty: is not one the catalog knows.",
|
|
31
31
|
inputSchema: { run: z.string(), confirmed: z.boolean().optional() },
|
|
32
32
|
},
|
|
33
33
|
(a) => handle(() => preflight(deps, { run: a.run, confirmed: a.confirmed })),
|
|
@@ -37,7 +37,7 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
|
|
|
37
37
|
"climb",
|
|
38
38
|
{
|
|
39
39
|
description:
|
|
40
|
-
"Move a routed lane one rung up its ladder and record why. Returns the next rung, or top: true, and any hints. Dispatch the lane again at that rung on a fresh thread. Pass env: true when the environment caused it (a missing service, a broken tool, a usage limit), not the rung.",
|
|
40
|
+
"Move a routed lane one rung up its ladder and record why. Returns the next rung, or top: true, and any hints. Dispatch the lane again at that rung on a fresh thread. Pass env: true when the environment caused it (a missing service, a broken tool, a usage limit), not the rung. Refused with E_CLIMB_DESIGN when the evidence is a design question (Jev's finding answer is design, or a blocked climb's evidence is about lane ownership): send it to the architect instead.",
|
|
41
41
|
inputSchema: {
|
|
42
42
|
run: z.string(),
|
|
43
43
|
lane: z.string().regex(ID_PATTERN),
|
|
@@ -67,15 +67,16 @@ export function registerLaneTools(server: McpServer, deps: Deps): void {
|
|
|
67
67
|
"land",
|
|
68
68
|
{
|
|
69
69
|
description:
|
|
70
|
-
"Record a landed milestone after you commit it: its five-column ledger row (with the minutes it took) and state.md's last check and next step, with any hints. learned, when given, goes to this repo's knowledge.md.",
|
|
70
|
+
"Record a landed milestone after you commit it: its five-column ledger row (with the minutes it took) and state.md's last check and next step, with any hints. learned, when given, goes to this repo's knowledge.md. Returns digest: the milestone's digest (R/digests/<milestone>.md: A-lines, commit, lanes with rungs and climbs, reviewer findings, the verifier's verdict with carried items, minutes and tokens), for the milestone push to link. Refused with E_LAND_GATE unless, since the milestone's lanes started, a reviewer named reviewer-<milestone> ended ok (a dispatch, or a Claude subagent recorded with record_agent_run, role reviewer) and a verifier verdict naming the milestone was recorded ok (record_agent_run, role verifier; a headless verifier's reply opening VERDICT: PASS). A skip over an empty commit range is refused: commit first. skip: docs-only lands a milestone whose commit range changed only docs; skip: no-code one that changed no source file (put the evidence, e.g. a green pipeline, in evidence).",
|
|
71
71
|
inputSchema: {
|
|
72
72
|
run: z.string(),
|
|
73
|
-
milestone: z.string().
|
|
73
|
+
milestone: z.string().regex(ID_PATTERN),
|
|
74
74
|
what: z.string().min(1),
|
|
75
75
|
commit: z.string().regex(/^[0-9a-f]{7,40}$/),
|
|
76
76
|
evidence: z.string().min(1),
|
|
77
77
|
next: z.string().min(1),
|
|
78
78
|
learned: z.string().min(1).optional(),
|
|
79
|
+
skip: z.enum(LAND_SKIPS).optional(),
|
|
79
80
|
},
|
|
80
81
|
},
|
|
81
82
|
(a) => handle(() => land(deps, a)),
|