catherd-cli 1.3.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 +144 -42
- 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 +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- 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/cli.ts +1 -64
- 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 +53 -19
- 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/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -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 +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -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 +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- 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/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- 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/peer-inbox.ts +4 -1
- 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 +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- 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 +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- 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 +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- 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 +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- 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 +86 -11
- package/src/services/treat-likes.ts +41 -12
- 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
package/src/domain/select.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
} from "./catalog.ts";
|
|
10
10
|
import { type BillingMode, type Cost, compareCost, costOf, DEFAULT_BILLING } from "./cost.ts";
|
|
11
11
|
import { CatherdError } from "./errors.ts";
|
|
12
|
+
import { quotaOf } from "./failover.ts";
|
|
13
|
+
import { tryParseRung } from "./ids.ts";
|
|
12
14
|
import { DIFFICULTIES, type Difficulty, type Kind } from "./lane.ts";
|
|
13
15
|
import type { Role } from "./roles.ts";
|
|
14
16
|
|
|
@@ -17,6 +19,11 @@ export interface RoutingProfile {
|
|
|
17
19
|
objective: "cost" | "speed";
|
|
18
20
|
billing: Partial<Record<string, BillingMode>>;
|
|
19
21
|
role: { enabled: boolean; rungs: string[]; defaultRung?: string };
|
|
22
|
+
/**
|
|
23
|
+
* spec 1.5 plan 24: the run's dispatches so far per quota (`quotaOf`), the headroom a tie between rungs of
|
|
24
|
+
* equal scores is broken on; absent, every quota counts 0 and the candidates' order breaks it
|
|
25
|
+
*/
|
|
26
|
+
usage?: Partial<Record<string, number>>;
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
export interface Candidate {
|
|
@@ -31,6 +38,10 @@ export interface Candidate {
|
|
|
31
38
|
export interface Pick {
|
|
32
39
|
rung: string;
|
|
33
40
|
ladder: string[];
|
|
41
|
+
/** spec 1.5 plan 24: no rung clears the lane's bar (`no rung clears repo_code/hard; best is …`) */
|
|
42
|
+
noClear?: string;
|
|
43
|
+
/** spec 1.5 plan 24: rungs of equal scores competed for the start, and how the tie was broken */
|
|
44
|
+
tie?: string;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
const byNull = (a: number | null, b: number | null) =>
|
|
@@ -44,12 +55,14 @@ function secsOf(c: Catalog, canonical: string, kind: Kind | null): number | null
|
|
|
44
55
|
* The role's enabled rungs that the catalog can place: parseable, offered by their model, listed by
|
|
45
56
|
* the backend when it has a listing, capable for the role, and scored (their own or a treat-like).
|
|
46
57
|
* Ordered by cost (spec §5.3), or by measured speed with cost breaking ties; with fewer than 5
|
|
47
|
-
* samples a rung has no speed, and cost orders it, which within a model is effort order.
|
|
58
|
+
* samples a rung has no speed, and cost orders it, which within a model is effort order. The profile's
|
|
59
|
+
* ladder order breaks every remaining tie (spec 1.5 plan 24), so equal rungs start in the order the user wrote.
|
|
48
60
|
*/
|
|
49
61
|
export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind | null = null): Candidate[] {
|
|
50
62
|
if (!p.role.enabled) return [];
|
|
51
63
|
const out: Candidate[] = [];
|
|
52
|
-
|
|
64
|
+
const order = [...new Set(p.role.rungs)];
|
|
65
|
+
for (const rung of order) {
|
|
53
66
|
let info: RungInfo;
|
|
54
67
|
try {
|
|
55
68
|
info = rungInfo(c, rung);
|
|
@@ -68,8 +81,11 @@ export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind
|
|
|
68
81
|
secs: secsOf(c, info.canonical, kind),
|
|
69
82
|
});
|
|
70
83
|
}
|
|
71
|
-
const
|
|
72
|
-
const
|
|
84
|
+
const written = (a: Candidate, b: Candidate) => order.indexOf(a.rung) - order.indexOf(b.rung);
|
|
85
|
+
const cost = (a: Candidate, b: Candidate) =>
|
|
86
|
+
compareCost(a.cost, b.cost) || byNull(a.secs, b.secs) || written(a, b);
|
|
87
|
+
const speed = (a: Candidate, b: Candidate) =>
|
|
88
|
+
byNull(a.secs, b.secs) || compareCost(a.cost, b.cost) || written(a, b);
|
|
73
89
|
return out.sort(p.objective === "speed" ? speed : cost);
|
|
74
90
|
}
|
|
75
91
|
|
|
@@ -85,15 +101,94 @@ function noRung(role: Role): CatherdError {
|
|
|
85
101
|
});
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
/** The dimensions a ladder is compared on: the bar's, else every dimension the start has a value on. */
|
|
105
|
+
function compareDims(start: Candidate, bar: Partial<Record<Dim, number>>): Dim[] {
|
|
106
|
+
const dims = (Object.keys(bar) as Dim[]).filter((d) => bar[d] !== undefined);
|
|
107
|
+
return dims.length ? dims : (Object.keys(start.scores) as Dim[]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `x` scores at least `start` on every one of `dims` (a missing value scores below any). */
|
|
111
|
+
const atLeast = (x: Candidate, start: Candidate, dims: Dim[]): boolean =>
|
|
112
|
+
dims.every(
|
|
113
|
+
(d) => (x.scores[d] ?? Number.NEGATIVE_INFINITY) >= (start.scores[d] ?? Number.NEGATIVE_INFINITY),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/** Spec 1.5 plan 24: how many of `rungs` (a run's dispatched rungs) drew on each quota. */
|
|
117
|
+
export function quotaUsage(rungs: readonly string[]): Record<string, number> {
|
|
118
|
+
const out: Record<string, number> = {};
|
|
119
|
+
for (const rung of rungs) {
|
|
120
|
+
const r = tryParseRung(rung);
|
|
121
|
+
if (r) out[quotaOf(r)] = (out[quotaOf(r)] ?? 0) + 1;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Equal on every one of `dims`: a tie for the start. */
|
|
127
|
+
const same = (a: Candidate, b: Candidate, dims: Dim[]): boolean => atLeast(a, b, dims) && atLeast(b, a, dims);
|
|
128
|
+
|
|
129
|
+
const quota = (x: Candidate): string => quotaOf(x.info.parsed);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Spec 1.5 plan 24: when rungs on other quotas score exactly as `start` does on `dims`, the start goes to the
|
|
133
|
+
* quota with the most headroom (the fewest of the run's dispatches so far), the candidates' order (cost, then
|
|
134
|
+
* the profile's ladder order) breaking an equal count. `tie` says what decided. Rungs of one quota that tie
|
|
135
|
+
* are no tie (cost already orders them), nor is a rung of another cost tier.
|
|
136
|
+
*/
|
|
137
|
+
function breakTie(
|
|
138
|
+
start: Candidate,
|
|
139
|
+
order: Candidate[],
|
|
140
|
+
dims: Dim[],
|
|
141
|
+
usage: Partial<Record<string, number>>,
|
|
142
|
+
): { start: Candidate; tie?: string } {
|
|
143
|
+
// a metered rung never wins a tie over a plan's: headroom is free only within the start's cost tier
|
|
144
|
+
const rivals = order.filter(
|
|
145
|
+
(x) =>
|
|
146
|
+
x !== start && quota(x) !== quota(start) && x.cost.tier === start.cost.tier && same(x, start, dims),
|
|
147
|
+
);
|
|
148
|
+
if (rivals.length === 0) return { start };
|
|
149
|
+
const used = (x: Candidate) => usage[quota(x)] ?? 0;
|
|
150
|
+
const tied = [start, ...rivals];
|
|
151
|
+
const pick = [...tied].sort((a, b) => used(a) - used(b))[0] as Candidate;
|
|
152
|
+
const others = tied.filter((x) => x !== pick).map((x) => x.rung);
|
|
153
|
+
const counts = [...new Set(tied.map(quota))].map((q) => `${q} ${usage[q] ?? 0}`).join(", ");
|
|
154
|
+
const why = tied.every((x) => used(x) === used(pick))
|
|
155
|
+
? `equal headroom (${used(pick)} dispatches each); the profile's ladder order and cost decided`
|
|
156
|
+
: `${quota(pick)} has the most headroom (dispatches in this run: ${counts})`;
|
|
157
|
+
return {
|
|
158
|
+
start: pick,
|
|
159
|
+
tie: `tie on ${dims.join(", ")} with ${others.join(", ")}: started on ${pick.rung}; ${why}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Spec 1.5 plan 24, "climb ladders only go up": the start (after a tie on equal scores goes to the quota with
|
|
165
|
+
* the most headroom), then, in `order`'s order, every other candidate that scores at least the last rung kept
|
|
166
|
+
* on `dims`. A stronger rung is on the ladder wherever cost puts it, and one weaker than the rung before it
|
|
167
|
+
* never is (checking each against the start alone let a cheaper, weaker rung follow a stronger one), so a
|
|
168
|
+
* climb never lands on a rung below the one it leaves.
|
|
169
|
+
*/
|
|
170
|
+
function ladderFrom(
|
|
171
|
+
first: Candidate,
|
|
172
|
+
order: Candidate[],
|
|
173
|
+
dims: Dim[],
|
|
174
|
+
usage: Partial<Record<string, number>> = {},
|
|
175
|
+
): Pick {
|
|
176
|
+
const { start, tie } = breakTie(first, order, dims, usage);
|
|
177
|
+
const kept = [start];
|
|
178
|
+
for (const x of order) if (x !== start && atLeast(x, kept.at(-1) as Candidate, dims)) kept.push(x);
|
|
179
|
+
return { rung: start.rung, ladder: kept.map((x) => x.rung), ...(tie ? { tie } : {}) };
|
|
180
|
+
}
|
|
181
|
+
|
|
88
182
|
/**
|
|
89
|
-
* The role's default rung and every candidate
|
|
90
|
-
* order under either objective: speed order would put slower, not
|
|
183
|
+
* The role's default rung and every candidate at least as strong on every dimension it has a value on (the
|
|
184
|
+
* whole list when it has no default), in cost order under either objective: speed order would put slower, not
|
|
185
|
+
* stronger, rungs above the default.
|
|
91
186
|
*/
|
|
92
187
|
export function defaultLadder(c: Catalog, p: RoutingProfile, role: Role): Pick {
|
|
93
|
-
const all = candidates(c, { ...p, objective: "cost" }, role)
|
|
188
|
+
const all = candidates(c, { ...p, objective: "cost" }, role);
|
|
94
189
|
if (all.length === 0) throw noRung(role);
|
|
95
|
-
const
|
|
96
|
-
return
|
|
190
|
+
const start = all.find((x) => x.rung === p.role.defaultRung) ?? (all[0] as Candidate);
|
|
191
|
+
return ladderFrom(start, all, compareDims(start, {}), p.usage);
|
|
97
192
|
}
|
|
98
193
|
|
|
99
194
|
/**
|
|
@@ -108,29 +203,86 @@ export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role, kin
|
|
|
108
203
|
return cleared.at(-1) ?? "build";
|
|
109
204
|
}
|
|
110
205
|
|
|
206
|
+
/** The kind's main dimension: what a speed ladder sorts on and what `best is` reads first. */
|
|
207
|
+
export const primaryDim = (kind: Kind): Dim =>
|
|
208
|
+
kind === "terminal" ? "terminal" : kind === "ui" ? "frontend" : "repo_code";
|
|
209
|
+
|
|
111
210
|
/**
|
|
112
211
|
* Under `objective: "speed"` the objective picks only the start (the fastest bar-clearing rung); the
|
|
113
|
-
* ladder above it is the other
|
|
114
|
-
*
|
|
212
|
+
* ladder above it is the other rungs at least as strong on the bar, weakest first on the kind's primary
|
|
213
|
+
* dimension, cost breaking ties, so a lane never climbs onto a weaker rung. Ported from 0.x.
|
|
115
214
|
*/
|
|
116
|
-
function speedLadder(
|
|
117
|
-
const
|
|
118
|
-
const dim: Dim = kind === "terminal" ? "terminal" : "repo_code";
|
|
215
|
+
function speedLadder(start: Candidate, all: Candidate[], kind: Kind, dims: Dim[], p: RoutingProfile): Pick {
|
|
216
|
+
const dim = primaryDim(kind);
|
|
119
217
|
const strength = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
const order = [...all].sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
|
|
219
|
+
return ladderFrom(start, order, dims, p.usage);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The thresholds of `bar` a rung misses, each with its value: `agentic 0.0818 < 0.1077`. */
|
|
223
|
+
function shortfalls(cand: Candidate, bar: Partial<Record<Dim, number>>): string[] {
|
|
224
|
+
return (Object.entries(bar) as [Dim, number | undefined][]).flatMap(([dim, min]) => {
|
|
225
|
+
if (min === undefined) return [];
|
|
226
|
+
const v = cand.scores[dim];
|
|
227
|
+
return v !== undefined && v >= min ? [] : [`${dim} ${v ?? "none"} < ${min}`];
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Spec 1.5 plan 24: when no rung clears a bar, the one closest to it: the fewest thresholds missed, then the
|
|
233
|
+
* highest on the kind's primary dimension, then the candidates' order.
|
|
234
|
+
*/
|
|
235
|
+
function bestOf(all: Candidate[], bar: Partial<Record<Dim, number>>, kind: Kind): Candidate {
|
|
236
|
+
const dim = primaryDim(kind);
|
|
237
|
+
const v = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
|
|
238
|
+
const misses = (x: Candidate) => shortfalls(x, bar).length;
|
|
239
|
+
return [...all].sort((a, b) => misses(a) - misses(b) || v(b) - v(a))[0] as Candidate;
|
|
124
240
|
}
|
|
125
241
|
|
|
126
|
-
/**
|
|
242
|
+
/** `no rung clears repo_code/hard; best is codex:gpt-6-sol#xhigh (agentic 0.0818 < 0.1077)`. */
|
|
243
|
+
export function noClearLine(
|
|
244
|
+
all: Candidate[],
|
|
245
|
+
bar: Partial<Record<Dim, number>>,
|
|
246
|
+
kind: Kind,
|
|
247
|
+
d: Difficulty,
|
|
248
|
+
): string {
|
|
249
|
+
const best = bestOf(all, bar, kind);
|
|
250
|
+
return `no rung clears ${kind}/${d}; best is ${best.rung} (${shortfalls(best, bar).join(", ")})`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). The start is the
|
|
255
|
+
* first rung in objective order that clears the bar. When none does, it is the default rung, raised to an
|
|
256
|
+
* easier difficulty's start when that one scores at least the default on this bar (spec 1.5 plan 24: `logic`
|
|
257
|
+
* starts no lower than `build`), and `noClear` says so. The ladder above the start only goes up.
|
|
258
|
+
*/
|
|
127
259
|
export function select(c: Catalog, p: RoutingProfile, role: Role, kind: Kind, difficulty: Difficulty): Pick {
|
|
128
260
|
const all = candidates(c, p, role, kind);
|
|
129
261
|
if (all.length === 0) throw noRung(role);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
262
|
+
const bar = c.bars[kind][difficulty];
|
|
263
|
+
if (all.length === 1) {
|
|
264
|
+
const only = all[0] as Candidate;
|
|
265
|
+
const one = { rung: only.rung, ladder: [only.rung] };
|
|
266
|
+
return clearsBar(c, only, kind, difficulty)
|
|
267
|
+
? one
|
|
268
|
+
: { ...one, noClear: noClearLine(all, bar, kind, difficulty) };
|
|
269
|
+
}
|
|
270
|
+
const first = all.find((x) => clearsBar(c, x, kind, difficulty));
|
|
271
|
+
if (first) {
|
|
272
|
+
const dims = compareDims(first, bar);
|
|
273
|
+
return p.objective === "speed"
|
|
274
|
+
? speedLadder(first, all, kind, dims, p)
|
|
275
|
+
: ladderFrom(first, all, dims, p.usage);
|
|
276
|
+
}
|
|
277
|
+
const byCost = candidates(c, { ...p, objective: "cost" }, role, kind);
|
|
278
|
+
const fallback = defaultLadder(c, p, role).rung;
|
|
279
|
+
let start = byCost.find((x) => x.rung === fallback) ?? (byCost[0] as Candidate);
|
|
280
|
+
const dims = compareDims(start, bar);
|
|
281
|
+
for (const easier of DIFFICULTIES.slice(0, DIFFICULTIES.indexOf(difficulty)).reverse()) {
|
|
282
|
+
const s = byCost.find((x) => clearsBar(c, x, kind, easier));
|
|
283
|
+
if (!s) continue;
|
|
284
|
+
if (atLeast(s, start, dims)) start = s;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
return { ...ladderFrom(start, byCost, dims, p.usage), noClear: noClearLine(all, bar, kind, difficulty) };
|
|
136
288
|
}
|
package/src/domain/state.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface StateView {
|
|
|
16
16
|
dirty: { path: string; owner: string | null }[];
|
|
17
17
|
running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
|
|
18
18
|
lastCheck: string | null;
|
|
19
|
+
/** spec 1.5 "Pinned per run": what changed since the pin; dispatch keeps the pinned values */
|
|
20
|
+
pinChanges?: string[];
|
|
19
21
|
next: string;
|
|
20
22
|
/** spec 1.1 §10: the protocol's next step, derived from the run's files; always the last line */
|
|
21
23
|
protocol: string;
|
|
@@ -40,6 +42,9 @@ export function renderState(s: StateView): string {
|
|
|
40
42
|
: ["- none"]),
|
|
41
43
|
"",
|
|
42
44
|
`Last check: ${s.lastCheck ?? "none"}`,
|
|
45
|
+
...(s.pinChanges?.length
|
|
46
|
+
? [`Pinned: ${s.pinChanges.join("; ")} (dispatch keeps the pinned values; run_pin re-pins)`]
|
|
47
|
+
: []),
|
|
43
48
|
"",
|
|
44
49
|
// 1.1: no tool waits; each running role's record arrives as a catherd message
|
|
45
50
|
`Next: ${waiting.length ? `running ${waiting.join(", ")} (results arrive as catherd messages; peek to check); then ${s.next}` : s.next}`,
|
package/src/domain/util.ts
CHANGED
|
@@ -16,6 +16,28 @@ export function median(xs: readonly number[]): number | null {
|
|
|
16
16
|
return s.length % 2 ? (s[m] as number) : ((s[m - 1] as number) + (s[m] as number)) / 2;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A stretch of time in epoch ms; `to` null while it is still open. */
|
|
20
|
+
export interface Span {
|
|
21
|
+
from: number;
|
|
22
|
+
to: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How many ms of [from, to] any of `spans` covers, overlaps counted once; an open span runs to `to`. */
|
|
26
|
+
export function coveredMs(from: number, to: number, spans: readonly Span[]): number {
|
|
27
|
+
const clipped = spans
|
|
28
|
+
.map((s) => [Math.max(from, s.from), Math.min(to, s.to ?? to)] as const)
|
|
29
|
+
.filter(([a, b]) => b > a)
|
|
30
|
+
.sort((x, y) => x[0] - y[0]);
|
|
31
|
+
let total = 0;
|
|
32
|
+
let end = -Infinity;
|
|
33
|
+
for (const [a, b] of clipped) {
|
|
34
|
+
if (b <= end) continue;
|
|
35
|
+
total += b - Math.max(a, end);
|
|
36
|
+
end = b;
|
|
37
|
+
}
|
|
38
|
+
return total;
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
/** A plain object: not null and not an array. */
|
|
20
42
|
export const isPlain = (v: unknown): v is Record<string, unknown> =>
|
|
21
43
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ID_PATTERN } from "./ids.ts";
|
|
3
|
+
|
|
4
|
+
export const WORKSPACE_LIMIT = 100;
|
|
5
|
+
|
|
6
|
+
export const WorkspaceIdSchema = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(ID_PATTERN)
|
|
9
|
+
.refine((s) => !s.includes(".."));
|
|
10
|
+
export const WorkspaceBudgetSchema = z.strictObject({
|
|
11
|
+
minutes: z.number().finite().nonnegative().optional(),
|
|
12
|
+
tokens: z.number().finite().nonnegative().optional(),
|
|
13
|
+
usd: z.number().finite().nonnegative().optional(),
|
|
14
|
+
});
|
|
15
|
+
/** A git ref a release waits on (`origin/main`): no option, no range, no whitespace. */
|
|
16
|
+
export const BaseRefSchema = z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/)
|
|
19
|
+
.refine((s) => !s.includes("..") && !s.endsWith("/") && !s.endsWith(".lock"));
|
|
20
|
+
export const RELEASES = ["land", "merge"] as const;
|
|
21
|
+
export const WorkspaceStepSchema = z
|
|
22
|
+
.strictObject({
|
|
23
|
+
id: WorkspaceIdSchema,
|
|
24
|
+
repo: WorkspaceIdSchema,
|
|
25
|
+
title: z.string().min(1),
|
|
26
|
+
aLines: z.array(z.string()),
|
|
27
|
+
dependsOn: z.array(WorkspaceIdSchema).max(WORKSPACE_LIMIT).default([]),
|
|
28
|
+
milestone: WorkspaceIdSchema.default("M1"),
|
|
29
|
+
/**
|
|
30
|
+
* When the step releases its dependents: once its milestone lands (the default), or once the landed
|
|
31
|
+
* commit is an ancestor of `base` in its repository (`git merge-base --is-ancestor`), checked when a
|
|
32
|
+
* dependent asks, never polled.
|
|
33
|
+
*/
|
|
34
|
+
release: z.enum(RELEASES).default("land"),
|
|
35
|
+
base: BaseRefSchema.optional(),
|
|
36
|
+
})
|
|
37
|
+
.refine((s) => (s.release === "merge") === (s.base !== undefined), {
|
|
38
|
+
message: 'release "merge" needs a base ref (e.g. origin/main), and only it takes one',
|
|
39
|
+
});
|
|
40
|
+
export type WorkspaceStep = z.infer<typeof WorkspaceStepSchema>;
|
|
41
|
+
export const WorkspaceSchema = z
|
|
42
|
+
.strictObject({
|
|
43
|
+
schema: z.literal(1),
|
|
44
|
+
id: WorkspaceIdSchema,
|
|
45
|
+
root: z.string().min(1),
|
|
46
|
+
title: z.string().min(1),
|
|
47
|
+
aLines: z.array(z.string()),
|
|
48
|
+
createdAt: z.iso.datetime(),
|
|
49
|
+
budget: WorkspaceBudgetSchema,
|
|
50
|
+
repos: z
|
|
51
|
+
.record(WorkspaceIdSchema, z.string().min(1))
|
|
52
|
+
.refine((repos) => Object.keys(repos).length <= WORKSPACE_LIMIT),
|
|
53
|
+
steps: z.array(WorkspaceStepSchema).min(1).max(WORKSPACE_LIMIT),
|
|
54
|
+
})
|
|
55
|
+
.superRefine((workspace, ctx) => {
|
|
56
|
+
// Zod may still refine an oversized array; do not traverse invalid graphs.
|
|
57
|
+
if (
|
|
58
|
+
workspace.steps.length > WORKSPACE_LIMIT ||
|
|
59
|
+
Object.keys(workspace.repos).length > WORKSPACE_LIMIT ||
|
|
60
|
+
workspace.steps.some((step) => step.dependsOn.length > WORKSPACE_LIMIT)
|
|
61
|
+
)
|
|
62
|
+
return;
|
|
63
|
+
const ids = new Set<string>();
|
|
64
|
+
let invalidIdentity = false;
|
|
65
|
+
for (const step of workspace.steps) {
|
|
66
|
+
if (ids.has(step.id) || !Object.hasOwn(workspace.repos, step.repo)) {
|
|
67
|
+
invalidIdentity = true;
|
|
68
|
+
ctx.addIssue({
|
|
69
|
+
code: "custom",
|
|
70
|
+
message: "steps require unique ids and repositories present in the snapshot",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
ids.add(step.id);
|
|
74
|
+
}
|
|
75
|
+
if (invalidIdentity) return;
|
|
76
|
+
const byId = new Map(workspace.steps.map((step) => [step.id, step]));
|
|
77
|
+
const remaining = new Map<string, number>();
|
|
78
|
+
const dependents = new Map<string, string[]>();
|
|
79
|
+
const ready: string[] = [];
|
|
80
|
+
for (const step of workspace.steps) {
|
|
81
|
+
if (
|
|
82
|
+
new Set(step.dependsOn).size !== step.dependsOn.length ||
|
|
83
|
+
step.dependsOn.some((id) => !byId.has(id))
|
|
84
|
+
) {
|
|
85
|
+
ctx.addIssue({
|
|
86
|
+
code: "custom",
|
|
87
|
+
message: "dependencies must form a graph without cycles or unknown steps",
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
remaining.set(step.id, step.dependsOn.length);
|
|
92
|
+
if (!step.dependsOn.length) ready.push(step.id);
|
|
93
|
+
for (const id of step.dependsOn) {
|
|
94
|
+
const next = dependents.get(id) ?? [];
|
|
95
|
+
next.push(step.id);
|
|
96
|
+
dependents.set(id, next);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const ancestors = new Map<string, Set<string>>();
|
|
100
|
+
const latestByRepo = new Map<string, string>();
|
|
101
|
+
// Topological order makes adjacent owners sufficient: if each owner follows
|
|
102
|
+
// the previous owner transitively, every pair of owners is ordered.
|
|
103
|
+
for (let i = 0; i < ready.length; i++) {
|
|
104
|
+
const step = byId.get(ready[i]!)!;
|
|
105
|
+
const before = new Set<string>();
|
|
106
|
+
for (const id of step.dependsOn) {
|
|
107
|
+
before.add(id);
|
|
108
|
+
for (const ancestor of ancestors.get(id)!) before.add(ancestor);
|
|
109
|
+
}
|
|
110
|
+
ancestors.set(step.id, before);
|
|
111
|
+
const previous = latestByRepo.get(step.repo);
|
|
112
|
+
if (previous && !before.has(previous))
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: "custom",
|
|
115
|
+
message: "steps sharing a repository must be ordered by dependencies",
|
|
116
|
+
});
|
|
117
|
+
latestByRepo.set(step.repo, step.id);
|
|
118
|
+
for (const id of dependents.get(step.id) ?? []) {
|
|
119
|
+
const count = remaining.get(id)! - 1;
|
|
120
|
+
remaining.set(id, count);
|
|
121
|
+
if (!count) ready.push(id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (ready.length !== workspace.steps.length)
|
|
125
|
+
ctx.addIssue({
|
|
126
|
+
code: "custom",
|
|
127
|
+
message: "dependencies must form a graph without cycles or unknown steps",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
export type Workspace = z.infer<typeof WorkspaceSchema>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
3
|
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
3
4
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
import { readDerived } from "../infra/sources/cache.ts";
|
|
13
14
|
import { type SyncReport, syncSources } from "../services/source-sync.ts";
|
|
14
15
|
import { clearTreatLike, type LeftOnStandIn, resetTreatLikes, suggestFor } from "../services/treat-likes.ts";
|
|
15
|
-
import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
|
|
16
|
+
import { exitCodeOf, JSON_ARG, mark, printError, refuseInRole } from "./cli-kit.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Spec 1.2 §9: a sync, one line per source (fetched, fresh, skipped, or failed with the answer it kept),
|
|
@@ -98,7 +99,7 @@ export function formatModel(m: CatalogModel): string {
|
|
|
98
99
|
// spec 1.2 §5.3: each value with its confidence and source, as `route` reports it; a guess names its rung
|
|
99
100
|
const values = Object.entries(r.scores).map(
|
|
100
101
|
([dim, v]) =>
|
|
101
|
-
`${dim} ${Number(v.value.toPrecision(4))} (${v.from ? `inferred from ${v.from}` : v.confidence}, ${v.source})`,
|
|
102
|
+
`${dim} ${Number(v.value.toPrecision(4))} (${v.from ? (v.lent === "treat-like" ? `like ${v.from}` : `inferred from ${v.from}`) : v.confidence}, ${v.source})`,
|
|
102
103
|
);
|
|
103
104
|
if (values.length === 0 && !r.evidence) continue;
|
|
104
105
|
lines.push(` #${r.rung.slice(r.rung.lastIndexOf("#") + 1)} ${values.join(" · ") || "unscored"}`);
|
|
@@ -162,10 +163,17 @@ const list = defineCommand({
|
|
|
162
163
|
|
|
163
164
|
/** Spec 1.2 §6.4: the profile rungs a removal leaves on an inferred stand-in or unscored, one line each. */
|
|
164
165
|
export function leftLines(left: LeftOnStandIn[], plain = false): string[] {
|
|
165
|
-
return left.map(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
return left.map((l) => {
|
|
167
|
+
const what = l.unscored
|
|
168
|
+
? ["unscored: routing skips it"]
|
|
169
|
+
: [
|
|
170
|
+
...(l.dims.length ? [`on an inferred stand-in for ${l.dims.join(", ")}`] : []),
|
|
171
|
+
...(l.missing?.length
|
|
172
|
+
? [`with no ${l.missing.join(", ")} value: it clears no bar that needs one`]
|
|
173
|
+
: []),
|
|
174
|
+
];
|
|
175
|
+
return `${mark("warn", plain)} ${l.profile}: ${l.rung} is left ${what.join(", and ")}`;
|
|
176
|
+
});
|
|
169
177
|
}
|
|
170
178
|
|
|
171
179
|
/** Spec 1.2 §6.4 `--suggest`: the three nearest stand-ins, each with its distance and the features it rests on. */
|
|
@@ -194,6 +202,7 @@ const treatLike = defineCommand({
|
|
|
194
202
|
"Score an unscored rung as a scored one; --suggest ranks stand-ins, --clear and --reset remove your mappings",
|
|
195
203
|
},
|
|
196
204
|
args: {
|
|
205
|
+
...HOST_ARG,
|
|
197
206
|
rung: {
|
|
198
207
|
type: "positional",
|
|
199
208
|
required: false,
|
|
@@ -227,15 +236,16 @@ const treatLike = defineCommand({
|
|
|
227
236
|
else for (const l of suggestLines(r, args.suggest)) console.log(l);
|
|
228
237
|
return;
|
|
229
238
|
}
|
|
239
|
+
refuseInRole(process.env, "catherd catalog treat-like");
|
|
230
240
|
if (args.clear !== undefined) {
|
|
231
|
-
const r = await clearTreatLike(args.clear);
|
|
241
|
+
const r = await clearTreatLike(args.clear, terminalHost(args.host).host);
|
|
232
242
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
233
243
|
for (const l of leftLines(r.left)) console.log(l);
|
|
234
244
|
console.log(`${mark("ok")} ${r.rung} is no longer treated like ${r.like}`);
|
|
235
245
|
return;
|
|
236
246
|
}
|
|
237
247
|
if (args.reset) {
|
|
238
|
-
const r = await resetTreatLikes();
|
|
248
|
+
const r = await resetTreatLikes(terminalHost(args.host).host);
|
|
239
249
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
240
250
|
for (const l of leftLines(r.left)) console.log(l);
|
|
241
251
|
console.log(
|
package/src/entry/cli-kit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
2
|
+
import { roleScopeFromEnv } from "../domain/role-scope.ts";
|
|
2
3
|
import { RUN_NOT_FOUND_FIX } from "../services/run-store.ts";
|
|
3
4
|
import { glyph, type State } from "./glyphs.ts";
|
|
4
5
|
|
|
@@ -24,6 +25,21 @@ export function printError(e: Pick<CatherdError, "code" | "message" | "fix">): v
|
|
|
24
25
|
export const exitCodeOf = (e: unknown): number =>
|
|
25
26
|
isCatherdError(e) && e.code === "E_INPUT_INVALID" ? EXIT.usage : EXIT.error;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Spec 1.5 plan 21: a role's shell (CATHERD_ROLE, or its scratch TMPDIR) never runs a command that writes the
|
|
30
|
+
* orchestrator's state (pause, runs cancel, profile set, …): E_ROLE_SCOPE, as `catherd run-file` and `catherd gate`
|
|
31
|
+
* refuse an operation the role has not. `command` is the command as typed, like `catherd pause`.
|
|
32
|
+
*/
|
|
33
|
+
export function refuseInRole(env: Record<string, string | undefined>, command: string): void {
|
|
34
|
+
const scope = roleScopeFromEnv(env);
|
|
35
|
+
if (!scope) return;
|
|
36
|
+
throw new CatherdError(
|
|
37
|
+
"E_ROLE_SCOPE",
|
|
38
|
+
`${command} is the orchestrator's: this process runs ${scope.name} of run ${scope.run}`,
|
|
39
|
+
{ fix: "report it in your reply; the orchestrator does it" },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export const printJson = (v: unknown): void => console.log(JSON.stringify(v, null, 2));
|
|
28
44
|
|
|
29
45
|
/** The `--json` flag every read command takes. */
|
package/src/entry/deps.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { HostContext } from "../domain/host.ts";
|
|
2
|
+
import { roleScopeFromEnv } from "../domain/role-scope.ts";
|
|
1
3
|
import { readSessionEnv } from "../infra/claude-session.ts";
|
|
2
4
|
import { VERSION } from "../infra/version.ts";
|
|
3
5
|
import type { Deps } from "../services/ports.ts";
|
|
@@ -5,13 +7,16 @@ import { profileService } from "../services/profile-service.ts";
|
|
|
5
7
|
import { routingService } from "../services/routing-service.ts";
|
|
6
8
|
|
|
7
9
|
/** The services every entry point (MCP server, CLI commands) runs on. */
|
|
8
|
-
export function defaultDeps(): Deps {
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
export function defaultDeps(host: HostContext = { host: "unknown", session: null, conflict: null }): Deps {
|
|
11
|
+
const deps: Deps = {
|
|
12
|
+
host,
|
|
13
|
+
profiles: profileService(() => deps.host),
|
|
11
14
|
routing: routingService(),
|
|
12
15
|
version: VERSION,
|
|
13
16
|
pollMs: 250,
|
|
14
17
|
session: readSessionEnv(process.env),
|
|
15
18
|
now: Date.now,
|
|
19
|
+
role: roleScopeFromEnv(process.env),
|
|
16
20
|
};
|
|
21
|
+
return deps;
|
|
17
22
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import { HOST_ARG, terminalHost, withThread } from "./host-arg.ts";
|
|
1
4
|
import { defineCommand } from "citty";
|
|
2
5
|
import { VERSION } from "../infra/version.ts";
|
|
3
6
|
import { type DoctorReport, doctor } from "../services/doctor.ts";
|
|
4
|
-
import { probePush } from "../services/doctor-push.ts";
|
|
5
7
|
import type { Check } from "../services/doctor-checks.ts";
|
|
6
8
|
import { EXIT, JSON_ARG, mark, printJson } from "./cli-kit.ts";
|
|
7
9
|
import { mcpHandshake } from "./mcp/handshake.ts";
|
|
10
|
+
import { probeRoleServer } from "../infra/role-mcp.ts";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* One row per check: `✓ ready Bun — 1.4.2`, then the full fix on its own line; a fix of several lines
|
|
@@ -34,15 +37,39 @@ export const doctorCommand = defineCommand({
|
|
|
34
37
|
description: "Readiness report: Bun, backends, Jev, the plugin, agents, the MCP server, locks",
|
|
35
38
|
},
|
|
36
39
|
args: {
|
|
40
|
+
...HOST_ARG,
|
|
37
41
|
...JSON_ARG,
|
|
42
|
+
"test-push": {
|
|
43
|
+
type: "boolean",
|
|
44
|
+
description:
|
|
45
|
+
"send one labeled smoke to the validated original session (receipt does not prove processing)",
|
|
46
|
+
},
|
|
47
|
+
thread: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description:
|
|
50
|
+
"with --test-push: the Codex thread to send the smoke to (Codex does not export it to the commands it runs)",
|
|
51
|
+
},
|
|
52
|
+
docker: {
|
|
53
|
+
type: "boolean",
|
|
54
|
+
description:
|
|
55
|
+
"also probe Docker as a gate sees it: two compose services reaching each other by name, and the free disk (pulls busybox)",
|
|
56
|
+
},
|
|
38
57
|
plain: { type: "boolean", description: "ASCII glyphs (NO_COLOR drops only colour)" },
|
|
39
58
|
},
|
|
40
59
|
async run({ args }) {
|
|
60
|
+
if (args.thread !== undefined && args["test-push"] !== true)
|
|
61
|
+
throw new CatherdError("E_INPUT_INVALID", "--thread only names where --test-push sends its smoke", {
|
|
62
|
+
fix: `catherd doctor --test-push --thread ${args.thread}`,
|
|
63
|
+
});
|
|
41
64
|
const r = await doctor({
|
|
65
|
+
host: withThread(terminalHost(args.host), args.thread),
|
|
66
|
+
repo: await gitToplevel(process.cwd()),
|
|
42
67
|
bunVersion: Bun.version,
|
|
43
68
|
version: VERSION,
|
|
44
69
|
handshake: () => mcpHandshake(),
|
|
45
|
-
|
|
70
|
+
roleServerStart: () => probeRoleServer(),
|
|
71
|
+
testPush: args["test-push"] === true,
|
|
72
|
+
docker: args.docker === true,
|
|
46
73
|
});
|
|
47
74
|
if (args.json) printJson(r);
|
|
48
75
|
else for (const l of formatReport(r, args.plain === true)) console.log(l);
|