catherd-cli 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -30
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +5 -0
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +110 -23
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +49 -9
- package/src/services/treat-likes.ts +19 -3
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
package/src/domain/route.ts
CHANGED
|
@@ -43,6 +43,35 @@ export interface RouteRow {
|
|
|
43
43
|
jev?: RouteJev | null;
|
|
44
44
|
/** climb rows: the climb was caused by the environment, not the rung's capability (spec §5.6) */
|
|
45
45
|
env?: boolean;
|
|
46
|
+
/** route rows (spec 1.5 plan 24): the decision in one line, as `route` returned it */
|
|
47
|
+
why?: string;
|
|
48
|
+
/** route rows: where Jev disagreed with the lane's declared Kind/Difficulty */
|
|
49
|
+
jevSaid?: string;
|
|
50
|
+
/** route rows: no rung clears the lane's bar, and the closest one */
|
|
51
|
+
noClear?: string;
|
|
52
|
+
/** route rows: rungs of equal scores on other quotas competed for the start, and what decided */
|
|
53
|
+
tie?: string;
|
|
54
|
+
/** route rows: the chosen rung's thresholds, values and sources (spec 1.2 §5.3), kept out of `route`'s answer */
|
|
55
|
+
provenance?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Spec 1.5 plan 24: one routes.jsonl row for a role's decision outside a lane: `route` without a lane file, or
|
|
60
|
+
* a lane-less `dispatch` (its `name`). `readRoutes` skips these rows: they have no lane to climb.
|
|
61
|
+
*/
|
|
62
|
+
export interface RoleRouteRow {
|
|
63
|
+
at: string;
|
|
64
|
+
lane: null;
|
|
65
|
+
role: Role;
|
|
66
|
+
/** the dispatch's name; null for a `route` */
|
|
67
|
+
name: string | null;
|
|
68
|
+
rung: string;
|
|
69
|
+
ladder: string[];
|
|
70
|
+
source: "route" | "dispatch";
|
|
71
|
+
/** who chose the rung: the router (its source), or the coordinator passing `rung` to `dispatch` */
|
|
72
|
+
decidedBy: RouteSource | "orchestrator";
|
|
73
|
+
why: string;
|
|
74
|
+
provenance?: unknown;
|
|
46
75
|
}
|
|
47
76
|
|
|
48
77
|
export function nextRung(ladder: string[], current: string): string | null {
|
|
@@ -118,6 +147,50 @@ export function laneOutcome(rows: RouteRow[], lane: string, landed: boolean, at:
|
|
|
118
147
|
};
|
|
119
148
|
}
|
|
120
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Spec 1.5 plan 24: a lane's final outcome in routes.jsonl, beside the route row that holds Jev's answer, for
|
|
152
|
+
* calibrating Jev's difficulty question: what Jev said and what the route used, whether the lane climbed, and
|
|
153
|
+
* how it ended. `readRoutes` skips these rows; outcomes.jsonl keeps its own (spec §5.6).
|
|
154
|
+
*/
|
|
155
|
+
export interface OutcomeRouteRow {
|
|
156
|
+
at: string;
|
|
157
|
+
lane: string;
|
|
158
|
+
source: "outcome";
|
|
159
|
+
decidedBy: RouteSource;
|
|
160
|
+
kind: Kind | null;
|
|
161
|
+
difficulty: Difficulty | null;
|
|
162
|
+
questionSet: string | null;
|
|
163
|
+
jev: RouteJev | null;
|
|
164
|
+
/** some climb moved the lane to another rung (an environment climb included) */
|
|
165
|
+
climbed: boolean;
|
|
166
|
+
startRung: string;
|
|
167
|
+
finalRung: string;
|
|
168
|
+
landed: boolean;
|
|
169
|
+
start_ok: boolean;
|
|
170
|
+
envCaused: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The routes.jsonl outcome row of `o`, with the kind and difficulty of the lane's last route. */
|
|
174
|
+
export function outcomeRouteRow(rows: RouteRow[], o: OutcomeRow): OutcomeRouteRow {
|
|
175
|
+
const start = rows.findLast((r) => r.lane === o.lane && r.source === "route");
|
|
176
|
+
return {
|
|
177
|
+
at: o.at,
|
|
178
|
+
lane: o.lane,
|
|
179
|
+
source: "outcome",
|
|
180
|
+
decidedBy: o.source,
|
|
181
|
+
kind: start?.kind ?? null,
|
|
182
|
+
difficulty: start?.difficulty ?? null,
|
|
183
|
+
questionSet: o.questionSet,
|
|
184
|
+
jev: o.jevProbs,
|
|
185
|
+
climbed: o.climbs.length > 0,
|
|
186
|
+
startRung: o.startRung,
|
|
187
|
+
finalRung: o.finalRung,
|
|
188
|
+
landed: o.landed,
|
|
189
|
+
start_ok: o.start_ok,
|
|
190
|
+
envCaused: o.envCaused,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
121
194
|
/** Spec §5.6: one row per lane, the last one written (last row per lane wins), in first-seen lane order. */
|
|
122
195
|
export function latestOutcomes(rows: OutcomeRow[]): OutcomeRow[] {
|
|
123
196
|
const byLane = new Map<string, OutcomeRow>();
|
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>;
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { readDerived } from "../infra/sources/cache.ts";
|
|
14
14
|
import { type SyncReport, syncSources } from "../services/source-sync.ts";
|
|
15
15
|
import { clearTreatLike, type LeftOnStandIn, resetTreatLikes, suggestFor } from "../services/treat-likes.ts";
|
|
16
|
-
import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
|
|
16
|
+
import { exitCodeOf, JSON_ARG, mark, printError, refuseInRole } from "./cli-kit.ts";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Spec 1.2 §9: a sync, one line per source (fetched, fresh, skipped, or failed with the answer it kept),
|
|
@@ -99,7 +99,7 @@ export function formatModel(m: CatalogModel): string {
|
|
|
99
99
|
// spec 1.2 §5.3: each value with its confidence and source, as `route` reports it; a guess names its rung
|
|
100
100
|
const values = Object.entries(r.scores).map(
|
|
101
101
|
([dim, v]) =>
|
|
102
|
-
`${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})`,
|
|
103
103
|
);
|
|
104
104
|
if (values.length === 0 && !r.evidence) continue;
|
|
105
105
|
lines.push(` #${r.rung.slice(r.rung.lastIndexOf("#") + 1)} ${values.join(" · ") || "unscored"}`);
|
|
@@ -163,10 +163,17 @@ const list = defineCommand({
|
|
|
163
163
|
|
|
164
164
|
/** Spec 1.2 §6.4: the profile rungs a removal leaves on an inferred stand-in or unscored, one line each. */
|
|
165
165
|
export function leftLines(left: LeftOnStandIn[], plain = false): string[] {
|
|
166
|
-
return left.map(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
});
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
/** Spec 1.2 §6.4 `--suggest`: the three nearest stand-ins, each with its distance and the features it rests on. */
|
|
@@ -229,6 +236,7 @@ const treatLike = defineCommand({
|
|
|
229
236
|
else for (const l of suggestLines(r, args.suggest)) console.log(l);
|
|
230
237
|
return;
|
|
231
238
|
}
|
|
239
|
+
refuseInRole(process.env, "catherd catalog treat-like");
|
|
232
240
|
if (args.clear !== undefined) {
|
|
233
241
|
const r = await clearTreatLike(args.clear, terminalHost(args.host).host);
|
|
234
242
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
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,4 +1,5 @@
|
|
|
1
1
|
import type { HostContext } from "../domain/host.ts";
|
|
2
|
+
import { roleScopeFromEnv } from "../domain/role-scope.ts";
|
|
2
3
|
import { readSessionEnv } from "../infra/claude-session.ts";
|
|
3
4
|
import { VERSION } from "../infra/version.ts";
|
|
4
5
|
import type { Deps } from "../services/ports.ts";
|
|
@@ -15,6 +16,7 @@ export function defaultDeps(host: HostContext = { host: "unknown", session: null
|
|
|
15
16
|
pollMs: 250,
|
|
16
17
|
session: readSessionEnv(process.env),
|
|
17
18
|
now: Date.now,
|
|
19
|
+
role: roleScopeFromEnv(process.env),
|
|
18
20
|
};
|
|
19
21
|
return deps;
|
|
20
22
|
}
|