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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { replyContract } from "./role-prompts.ts";
|
|
2
|
+
import { ROLE_MCP_SERVER, roleMcpTools } from "./role-tools.ts";
|
|
3
|
+
import type { Role } from "./roles.ts";
|
|
4
|
+
|
|
5
|
+
/** What admission adds to a role's brief, besides its reply contract (spec 1.5 plan 21). */
|
|
6
|
+
export interface BriefContext {
|
|
7
|
+
run: string;
|
|
8
|
+
name: string;
|
|
9
|
+
role: Role;
|
|
10
|
+
/** the lane file as it stands at admission, header and body */
|
|
11
|
+
lane: { id: string; text: string } | null;
|
|
12
|
+
/** the role's scratch folder, its TMPDIR, on a backend that grants it */
|
|
13
|
+
scratch: string | null;
|
|
14
|
+
/** whether the role's harness lists the catherd_role tools */
|
|
15
|
+
roleServer: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The CLI form of each role server tool, which any role can run from its shell. */
|
|
19
|
+
function cliForm(tool: string, run: string): string {
|
|
20
|
+
switch (tool) {
|
|
21
|
+
case "read_run_file":
|
|
22
|
+
return `catherd run-file read ${run} <path>`;
|
|
23
|
+
case "write_run_file":
|
|
24
|
+
return `catherd run-file write ${run} <path> (the content on stdin)`;
|
|
25
|
+
case "read_knowledge":
|
|
26
|
+
return "catherd knowledge show";
|
|
27
|
+
case "gate_check":
|
|
28
|
+
return `catherd gate check ${run} --milestone <M> --item <item> --command <command> --paths <path,…>`;
|
|
29
|
+
case "gate_pass":
|
|
30
|
+
return `catherd gate pass ${run} --item <item> --command <command> --paths <path,…> --evidence <evidence>`;
|
|
31
|
+
default:
|
|
32
|
+
return tool;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LANE_OPEN = (id: string) => `<lane-file path="lanes/${id}.md">`;
|
|
37
|
+
const LANE_CLOSE = "</lane-file>";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The lane file inlined (spec 1.5 plan 21): an isolated headless worker cannot read the run folder, so its Owns,
|
|
41
|
+
* fast check and body travel in the brief itself.
|
|
42
|
+
*/
|
|
43
|
+
export function laneBlock(id: string, text: string): string {
|
|
44
|
+
return [
|
|
45
|
+
`Your lane file, lanes/${id}.md, as it stood when you were dispatched:`,
|
|
46
|
+
LANE_OPEN(id),
|
|
47
|
+
text.trimEnd(),
|
|
48
|
+
LANE_CLOSE,
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Who the role is, where its temp files go, and how it reaches its run's files and gate. */
|
|
53
|
+
export function roleNotes(c: BriefContext): string {
|
|
54
|
+
const tools = roleMcpTools(c.role);
|
|
55
|
+
const lines = [`catherd: you are ${c.name} of run ${c.run}.`];
|
|
56
|
+
if (c.scratch)
|
|
57
|
+
lines.push(
|
|
58
|
+
`Your scratch folder is ${c.scratch}, which is your $TMPDIR: put temporary files, logs and builds there, never in /tmp. catherd removes it with the run, so anything the orchestrator must keep goes in your reply.`,
|
|
59
|
+
);
|
|
60
|
+
const cli = tools.map((t) => cliForm(t, c.run)).join("; ");
|
|
61
|
+
lines.push(
|
|
62
|
+
c.roleServer
|
|
63
|
+
? `Your catherd tools: ${tools.map((t) => `mcp__${ROLE_MCP_SERVER}__${t}`).join(", ")}. If they are not listed, run the same from your shell: ${cli}.`
|
|
64
|
+
: `Your catherd commands, from your shell: ${cli}.`,
|
|
65
|
+
);
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The brief admission writes: the orchestrator's text, the lane file, the role's notes, then its reply contract,
|
|
71
|
+
* each once. A failover stand-in that reruns an earlier brief gets the lane file as it stands now and its own notes,
|
|
72
|
+
* never two copies.
|
|
73
|
+
*/
|
|
74
|
+
export function composeBrief(brief: string, c: BriefContext): string {
|
|
75
|
+
const contract = replyContract(c.role);
|
|
76
|
+
let body = brief.trimEnd();
|
|
77
|
+
if (body.endsWith(contract)) body = body.slice(0, -contract.length).trimEnd();
|
|
78
|
+
if (c.lane) {
|
|
79
|
+
const open = LANE_OPEN(c.lane.id);
|
|
80
|
+
const start = body.lastIndexOf(open);
|
|
81
|
+
const end = body.indexOf(LANE_CLOSE, start);
|
|
82
|
+
if (start >= 0 && end >= 0) {
|
|
83
|
+
const lead = body.lastIndexOf("\n\n", start);
|
|
84
|
+
body = `${body.slice(0, lead >= 0 ? lead : start)}${body.slice(end + LANE_CLOSE.length)}`.trimEnd();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const notesStart = body.lastIndexOf("\n\ncatherd: you are ");
|
|
88
|
+
if (notesStart >= 0) body = body.slice(0, notesStart).trimEnd();
|
|
89
|
+
const parts = [body, ...(c.lane ? [laneBlock(c.lane.id, c.lane.text)] : []), roleNotes(c), contract];
|
|
90
|
+
return `${parts.filter(Boolean).join("\n\n")}\n`;
|
|
91
|
+
}
|
|
@@ -34,6 +34,13 @@ export const DIM_SOURCES: Record<Dim, { anchor: FieldRef | "shipped"; others: Fi
|
|
|
34
34
|
benchmark: "Artificial Analysis Coding Index",
|
|
35
35
|
},
|
|
36
36
|
{ source: "epoch", field: "frontiercode", benchmark: "FrontierCode (Epoch AI)" },
|
|
37
|
+
// spec 1.5 plan 24: per model and effort, it scores a new release the day it ships (the identity run's
|
|
38
|
+
// GPT-6.1 Sol: medium 47.8, about Astra low)
|
|
39
|
+
{
|
|
40
|
+
source: "artificial-analysis",
|
|
41
|
+
field: "artificial_analysis_intelligence_index",
|
|
42
|
+
benchmark: "Artificial Analysis Intelligence Index",
|
|
43
|
+
},
|
|
37
44
|
],
|
|
38
45
|
},
|
|
39
46
|
// plan 14 Ruling C-2: the vendors' Terminal-Bench 4.0 values, not Epoch's Terminal-Bench 2.0, which shares
|
package/src/domain/catalog.ts
CHANGED
|
@@ -84,6 +84,11 @@ const FamilySchema = z.looseObject({
|
|
|
84
84
|
notes: z.record(z.string(), z.string()).default({}),
|
|
85
85
|
/** the day the vendor released it (models.dev), a stand-in feature (spec 1.2 §6.3) */
|
|
86
86
|
releaseDate: z.iso.date().optional(),
|
|
87
|
+
/**
|
|
88
|
+
* spec 1.5 plan 24: the family this one succeeds in the same line (GPT-6.1 Sol after GPT-6 Sol): until a
|
|
89
|
+
* value of its own arrives, a rung takes at least its predecessor's value at the same effort
|
|
90
|
+
*/
|
|
91
|
+
predecessor: z.string().min(1).optional(),
|
|
87
92
|
/** a sync's speed facts (spec 1.2 §4.1), `<source>.<field>` → value; they never carry a bar */
|
|
88
93
|
speed: z.record(z.string(), z.number()).optional(),
|
|
89
94
|
});
|
|
@@ -211,6 +216,8 @@ export interface Catalog {
|
|
|
211
216
|
* whose value it uses as `inferred` (spec 1.2 §6.1); filled by the stand-in ranking (services/standins.ts)
|
|
212
217
|
*/
|
|
213
218
|
inferred: Record<string, Partial<Record<Dim, InferredStandIn>>>;
|
|
219
|
+
/** the thresholds the user's override set or removed, as `<kind>/<difficulty>/<dim>` (route says "your override") */
|
|
220
|
+
userBars?: string[];
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
/**
|
|
@@ -317,11 +324,14 @@ export function buildCatalog(o: {
|
|
|
317
324
|
for (const [rung, like] of Object.entries(o.override?.treatLike ?? {}))
|
|
318
325
|
treatLike[rung] = { like, source: "user" };
|
|
319
326
|
const bars = structuredClone(o.scores.bars) as Bars;
|
|
327
|
+
const userBars: string[] = [];
|
|
320
328
|
for (const kind of KINDS)
|
|
321
329
|
for (const d of DIFFICULTIES)
|
|
322
|
-
for (const [dim, min] of Object.entries(o.override?.bars[kind]?.[d] ?? {}) as [Dim, number | null][])
|
|
330
|
+
for (const [dim, min] of Object.entries(o.override?.bars[kind]?.[d] ?? {}) as [Dim, number | null][]) {
|
|
331
|
+
userBars.push(`${kind}/${d}/${dim}`);
|
|
323
332
|
if (min === null) delete bars[kind][d][dim];
|
|
324
333
|
else bars[kind][d][dim] = min;
|
|
334
|
+
}
|
|
325
335
|
return {
|
|
326
336
|
families: o.facts ? applyFacts(o.models.families, o.facts) : o.models.families,
|
|
327
337
|
backends: o.models.backends,
|
|
@@ -333,6 +343,7 @@ export function buildCatalog(o: {
|
|
|
333
343
|
secs: o.secs ?? {},
|
|
334
344
|
features: o.features ?? {},
|
|
335
345
|
inferred: {},
|
|
346
|
+
userBars,
|
|
336
347
|
};
|
|
337
348
|
}
|
|
338
349
|
|
package/src/domain/changes.ts
CHANGED
|
@@ -33,6 +33,28 @@ export function changedPaths(before: Snapshot, after: Snapshot): string[] {
|
|
|
33
33
|
.sort();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Spec 1.5 plan 21: a writer's implicit docs lane, when its brief names no Owns. */
|
|
37
|
+
export const DOCS_OWNS = ["docs/**", "*.md"];
|
|
38
|
+
|
|
39
|
+
/** The paths of an `Owns:` line in a brief (as a lane file writes it), or null when it has none. */
|
|
40
|
+
export function briefOwns(brief: string): string[] | null {
|
|
41
|
+
const line = /^Owns:\s*(.+)$/m.exec(brief)?.[1];
|
|
42
|
+
const paths = line
|
|
43
|
+
?.split(",")
|
|
44
|
+
.map((p) => p.trim().replace(/^`|`$/g, ""))
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
return paths?.length ? paths : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `path` is inside an Owns entry: a path covers what is under it, and the other way round (as lanes'
|
|
51
|
+
* Owns always did); `dir/**` covers what is under dir; `*.ext` (no slash) is any file with that extension.
|
|
52
|
+
*/
|
|
53
|
+
export function ownsPath(path: string, entry: string): boolean {
|
|
54
|
+
if (entry.startsWith("*.") && !entry.includes("/")) return path.endsWith(entry.slice(1));
|
|
55
|
+
return overlaps([path], [entry.replace(/\/\*\*$/, "")]).length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/**
|
|
37
59
|
* Spec §4.4: `changedOwned` are the changes inside the lane's Owns; `violations` the ones outside it
|
|
38
60
|
* that no overlapping dispatch owns either. `strict` is false for a laneless dispatch that may write
|
|
@@ -44,7 +66,7 @@ export function splitChanges(
|
|
|
44
66
|
others: string[],
|
|
45
67
|
strict: boolean,
|
|
46
68
|
): { changedOwned: string[]; violations: string[] } {
|
|
47
|
-
const inside = (p: string, paths: string[]) =>
|
|
69
|
+
const inside = (p: string, paths: string[]) => paths.some((entry) => ownsPath(p, entry));
|
|
48
70
|
return {
|
|
49
71
|
changedOwned: changed.filter((p) => inside(p, owns)),
|
|
50
72
|
violations: strict ? changed.filter((p) => !inside(p, owns) && !inside(p, others)) : [],
|
package/src/domain/cost.ts
CHANGED
|
@@ -51,19 +51,24 @@ export const CHATGPT_UNIT_USD = 0.019;
|
|
|
51
51
|
export interface Cost {
|
|
52
52
|
/** 0: paid from a subscription; 1: metered. Every tier-0 rung ranks before any tier-1 rung. */
|
|
53
53
|
tier: 0 | 1;
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* comparable across billing modes, in list-price dollars per task; 0 for an unpriced rung paid from a plan or
|
|
56
|
+
* subscription (it spends nothing beyond the flat fee); null for an unpriced metered one
|
|
57
|
+
*/
|
|
55
58
|
value: number | null;
|
|
56
59
|
mode: BillingMode;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
|
-
* Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other:
|
|
63
|
+
* Spec §5.3. Every mode yields list-price dollars per task, so plans compare with each other. Spec 1.5 plan 24:
|
|
64
|
+
* a rung catherd has no price for costs 0 within its tier when a plan or subscription pays for it, so it can
|
|
65
|
+
* start a lane (the profile's ladder order then breaks the tie); a metered one stays unknown, last in its tier.
|
|
61
66
|
* chatgpt-plan scales the official quota weight (Luna 1, Sol 20, Astra 60) by the effort factor;
|
|
62
67
|
* claude-plan uses API prices as the proxy, with Fable metered (catherd cannot tell Pro from Max);
|
|
63
68
|
* Go's share of its monthly dollar limit is dollars / limit, which orders the same as dollars.
|
|
64
69
|
*/
|
|
65
70
|
export function costOf(family: Family | null, effort: string, mode: BillingMode): Cost {
|
|
66
|
-
if (!family) return
|
|
71
|
+
if (!family) return mode === "metered" ? { tier: 1, value: null, mode } : { tier: 0, value: 0, mode };
|
|
67
72
|
const usd = taskUsd(family.price, effort);
|
|
68
73
|
switch (mode) {
|
|
69
74
|
case "chatgpt-plan":
|
package/src/domain/errors.ts
CHANGED
|
@@ -17,9 +17,12 @@ export type ErrorCode =
|
|
|
17
17
|
| "E_ADMIT_OVERLAP"
|
|
18
18
|
| "E_ADMIT_ID"
|
|
19
19
|
| "E_ADMIT_THREAD"
|
|
20
|
+
| "E_ADMIT_PAUSED"
|
|
21
|
+
| "E_ADMIT_ORDER"
|
|
20
22
|
| "E_LANE_INVALID"
|
|
21
23
|
| "E_LAND_GATE"
|
|
22
24
|
| "E_CLIMB_DESIGN"
|
|
25
|
+
| "E_CLIMB_ENV"
|
|
23
26
|
| "E_JEV_KEY"
|
|
24
27
|
| "E_JEV_NETWORK"
|
|
25
28
|
| "E_JEV_RESPONSE"
|
|
@@ -28,6 +31,7 @@ export type ErrorCode =
|
|
|
28
31
|
| "E_IO_PATH"
|
|
29
32
|
| "E_IO_UNEXPECTED"
|
|
30
33
|
| "E_INPUT_INVALID"
|
|
34
|
+
| "E_ROLE_SCOPE"
|
|
31
35
|
| "E_RUNTIME_TOO_OLD";
|
|
32
36
|
|
|
33
37
|
/**
|
package/src/domain/failover.ts
CHANGED
|
@@ -45,10 +45,18 @@ export function barDims(c: Catalog, rung: string): Dim[] {
|
|
|
45
45
|
* well: no downgrade.
|
|
46
46
|
*/
|
|
47
47
|
export function downgradeDims(c: Catalog, rung: string, standIn: string): Dim[] {
|
|
48
|
-
|
|
48
|
+
return downgradeAgainst(valuesOf(c, rung), barDims(c, rung), valuesOf(c, standIn));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** downgradeDims with the rung's values and bar dims computed once, for a pool of stand-ins. */
|
|
52
|
+
function downgradeAgainst(
|
|
53
|
+
a: Partial<Record<Dim, number>> | null,
|
|
54
|
+
dims: Dim[],
|
|
55
|
+
standIn: Partial<Record<Dim, number>> | null,
|
|
56
|
+
): Dim[] {
|
|
49
57
|
if (!a) return [];
|
|
50
|
-
const b =
|
|
51
|
-
return
|
|
58
|
+
const b = standIn ?? {};
|
|
59
|
+
return dims.filter((d) => (b[d] ?? Number.NEGATIVE_INFINITY) < (a[d] as number));
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -85,11 +93,15 @@ export function rankStandIns(
|
|
|
85
93
|
): string[] {
|
|
86
94
|
const from = tryParseRung(rung);
|
|
87
95
|
if (!from) return [];
|
|
96
|
+
// the rung's own values and bar dims, once for the whole pool
|
|
97
|
+
const own = valuesOf(c, rung);
|
|
98
|
+
const dims = barDims(c, rung);
|
|
88
99
|
const fits = [...new Set(pool)].filter((x) => {
|
|
89
100
|
const r = tryParseRung(x);
|
|
90
|
-
|
|
101
|
+
const values = r ? valuesOf(c, x) : null;
|
|
102
|
+
if (!r || r.backend === "claude" || quotaOf(r) === quotaOf(from) || !values) return false;
|
|
91
103
|
if (costFor(c, billing, x).tier === 1) return false;
|
|
92
|
-
return
|
|
104
|
+
return downgradeAgainst(own, dims, values).length === 0;
|
|
93
105
|
});
|
|
94
106
|
const gap = (x: string) => effortGap(from.effort, tryParseRung(x)?.effort ?? "");
|
|
95
107
|
return fits.sort(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Plan 23: how a verifier runs a gate, which every verifier gets (its agent file, or its dispatch brief), and
|
|
2
|
+
// the run's own facts a dispatched verifier's brief adds: the items already recorded, the failed ones to
|
|
3
|
+
// re-check first, and the repo's gate environment.
|
|
4
|
+
|
|
5
|
+
/** A re-check's cap on each command, in minutes: a hung command fails its item, not the verifier. */
|
|
6
|
+
export const RECHECK_COMMAND_MIN = 10;
|
|
7
|
+
|
|
8
|
+
/** The rules of the gate, one paragraph each. */
|
|
9
|
+
export const VERIFIER_GATE_RULES = [
|
|
10
|
+
"Run the root gate (the milestone's full check) as one gate item and each per-service acceptance suite as its own item, never as one command: a failure then names its item, and a re-check reruns only that item. Acceptance that builds from HEAD (git archive, a commit's image) is yours: workers do not commit, so they never run it.",
|
|
11
|
+
"Stay in the foreground until your verdict: no background watcher, and never end your turn while a command runs.",
|
|
12
|
+
`When the machine, not the work, stops an item (a VPN filter, a Docker client proxy, a dead registry, no DNS), probe it directly, twice, 5 s apart, and if it still fails, reply VERDICT: BLOCKED: environment — <the probe and its output>. That is a blocker for the owner, not a FAIL.`,
|
|
13
|
+
"Last, when you built Docker images, run docker image prune -f, so the gates of later milestones do not fill the disk.",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** The marker a verifier brief's catherd section starts with: added once. */
|
|
17
|
+
export const GATE_NOTES_HEADING = "## The gate (catherd)";
|
|
18
|
+
|
|
19
|
+
/** The run's facts for one verifier dispatch. */
|
|
20
|
+
export interface GateNotes {
|
|
21
|
+
milestone: string | null;
|
|
22
|
+
/** the milestone's recorded items, with whether each passed */
|
|
23
|
+
recorded: { item: string; passed: boolean }[];
|
|
24
|
+
/** the repo's gate environment, as `NAME=value` or `NAME=$FROM` */
|
|
25
|
+
env: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The section a dispatched verifier's brief ends with (before the reply contract). */
|
|
29
|
+
export function gateNotes(n: GateNotes): string {
|
|
30
|
+
const failed = n.recorded.filter((r) => !r.passed).map((r) => r.item);
|
|
31
|
+
const lines = [GATE_NOTES_HEADING, "", ...VERIFIER_GATE_RULES.map((r) => `- ${r}`)];
|
|
32
|
+
if (n.milestone && n.recorded.length) {
|
|
33
|
+
lines.push(
|
|
34
|
+
`- Items already recorded for ${n.milestone}; reuse these names in gate_check, so what passed is carried over: ${n.recorded.map((r) => r.item).join(", ")}.`,
|
|
35
|
+
);
|
|
36
|
+
if (failed.length)
|
|
37
|
+
lines.push(
|
|
38
|
+
`- This is a re-check. Run the failed items first: ${failed.join(", ")}. Cap each command at ${RECHECK_COMMAND_MIN} minutes; a command that runs longer fails its item (say so), it never holds the gate.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (n.env.length)
|
|
42
|
+
lines.push(
|
|
43
|
+
`- The repo's gate environment is already set in your env (a secret as $NAME of where it came from): ${n.env.join(", ")}.`,
|
|
44
|
+
);
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `brief` with the gate notes appended, once: a brief that already has them is kept. */
|
|
49
|
+
export function withGateNotes(brief: string, n: GateNotes): string {
|
|
50
|
+
if (brief.includes(GATE_NOTES_HEADING)) return brief;
|
|
51
|
+
return `${brief.trimEnd()}\n\n${gateNotes(n)}\n`;
|
|
52
|
+
}
|
package/src/domain/hints.ts
CHANGED
|
@@ -3,11 +3,22 @@ import type { RunRecord } from "./record.ts";
|
|
|
3
3
|
/** Spec §4.4: what the orchestrator does next, read off one finished record. `dir` is the dispatch folder, relative to the run. */
|
|
4
4
|
export function dispatchHints(r: RunRecord, owns: string[], dir: string): string[] {
|
|
5
5
|
const h: string[] = [];
|
|
6
|
-
if (r.status === "limit")
|
|
6
|
+
if (r.status === "limit")
|
|
7
|
+
h.push(
|
|
8
|
+
r.error?.code === "provider-unavailable"
|
|
9
|
+
? `limit: ${r.rung}'s provider is unavailable (${r.error.message}); it fails over as on a usage limit`
|
|
10
|
+
: `limit: ${r.backend} hit a usage limit on ${r.rung}`,
|
|
11
|
+
);
|
|
7
12
|
if (r.status === "cli-too-old") h.push(`cli-too-old: ${r.error?.message ?? `upgrade ${r.backend}`}`);
|
|
8
13
|
if (r.status === "failed") h.push(`failed: read ${dir}/stderr`);
|
|
9
14
|
if (r.gitUnavailable) h.push("git-unavailable: changed files unknown");
|
|
10
|
-
|
|
15
|
+
// plan 23: the environment stopped it, and a higher rung would stop the same way: fix it or surface it
|
|
16
|
+
if (r.environment) h.push(`environment: ${r.environment}`);
|
|
17
|
+
else if (r.replyStatus === "flaky")
|
|
18
|
+
h.push(
|
|
19
|
+
`flaky: ${r.replyWhy || "a check failed, then passed alone"}; rerun it alone, accept it, or climb: your call`,
|
|
20
|
+
);
|
|
21
|
+
else if (r.replyStatus === "refused" || r.replyStatus === "blocked") h.push(`climb: ${r.replyStatus}`);
|
|
11
22
|
else if (
|
|
12
23
|
r.status === "ok" &&
|
|
13
24
|
!r.gitUnavailable &&
|
package/src/domain/lane.ts
CHANGED
|
@@ -11,18 +11,47 @@ export interface LaneHeader {
|
|
|
11
11
|
fastCheck: string | null;
|
|
12
12
|
kind: Kind | null;
|
|
13
13
|
difficulty: Difficulty | null;
|
|
14
|
+
/** `After: M1.L1, M1.L2`: lanes that must finish first (protocol.next and admission keep the order) */
|
|
15
|
+
after: string[];
|
|
16
|
+
/** `Allow: path[:line], …` under the check: hits of the lane's absence check that are allowed exceptions */
|
|
17
|
+
allow: string[];
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/** A lane id in an After: line: Mx.Ly, the shape lanes/<id>.md takes. */
|
|
21
|
+
const LANE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
|
|
23
|
+
/** An Allow: entry: a repo-relative path, optionally `:<line>`. */
|
|
24
|
+
const ALLOW_ENTRY = /^([^\s:]+)(?::(\d+))?$/;
|
|
25
|
+
|
|
16
26
|
const unquote = (s: string) =>
|
|
17
27
|
s
|
|
18
28
|
.trim()
|
|
19
29
|
.replace(/^`+|`+$/g, "")
|
|
20
30
|
.trim();
|
|
21
31
|
|
|
22
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The lane's header block, as [start, end) line indexes: the lines under the `# ` title (blank lines right
|
|
34
|
+
* after it skipped) up to the first blank line or heading. Header lines are read only here, so a body's prose
|
|
35
|
+
* (`After: the handler returns 404`) is never a header value.
|
|
36
|
+
*/
|
|
37
|
+
export function headerBlock(lines: string[]): [number, number] {
|
|
38
|
+
const title = lines.findIndex((l) => /^#\s+/.test(l));
|
|
39
|
+
let start = title + 1;
|
|
40
|
+
while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
|
|
41
|
+
let end = start;
|
|
42
|
+
while (end < lines.length && (lines[end] ?? "").trim() !== "" && !(lines[end] ?? "").startsWith("#")) end++;
|
|
43
|
+
return [start, end];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A header line `Label:`, allowing `**Label:**` and `_Label_:` emphasis; group 1 is its value. */
|
|
47
|
+
export const headerLine = (label: string) => new RegExp(`^\\s*[*_]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, "i");
|
|
48
|
+
|
|
49
|
+
/** The value of the first `Label:` line of the header block. */
|
|
23
50
|
function field(text: string, label: string): string | null {
|
|
24
|
-
const re =
|
|
25
|
-
|
|
51
|
+
const re = headerLine(label);
|
|
52
|
+
const lines = text.split("\n");
|
|
53
|
+
const [start, end] = headerBlock(lines);
|
|
54
|
+
for (const line of lines.slice(start, end)) {
|
|
26
55
|
const m = re.exec(line);
|
|
27
56
|
if (m) return (m[1] ?? "").trim();
|
|
28
57
|
}
|
|
@@ -51,15 +80,81 @@ export function parseLaneHeader(text: string): LaneHeader {
|
|
|
51
80
|
const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? null;
|
|
52
81
|
const owns = (field(text, "owns") ?? "").split(",").map(unquote).filter(Boolean).map(normalizeOwned);
|
|
53
82
|
const check = field(text, "fast check");
|
|
83
|
+
const list = (label: string) => (field(text, label) ?? "").split(",").map(unquote).filter(Boolean);
|
|
54
84
|
return {
|
|
55
85
|
title,
|
|
56
86
|
owns,
|
|
57
87
|
fastCheck: check ? unquote(check) || null : null,
|
|
58
88
|
kind: oneOf(field(text, "kind"), KINDS),
|
|
59
89
|
difficulty: oneOf(field(text, "difficulty"), DIFFICULTIES),
|
|
90
|
+
after: list("after"),
|
|
91
|
+
allow: list("allow"),
|
|
60
92
|
};
|
|
61
93
|
}
|
|
62
94
|
|
|
95
|
+
/** What is wrong with the After: and Allow: entries, one line each; none when they are well formed. */
|
|
96
|
+
function orderProblems(h: LaneHeader): string[] {
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
for (const a of h.after) if (!LANE_ID.test(a)) out.push(`After "${a}" is not a lane id like M1.L1`);
|
|
99
|
+
for (const a of h.allow) {
|
|
100
|
+
const m = ALLOW_ENTRY.exec(a);
|
|
101
|
+
try {
|
|
102
|
+
if (!m) throw new Error();
|
|
103
|
+
normalizeOwned(m[1] as string);
|
|
104
|
+
} catch {
|
|
105
|
+
out.push(`Allow "${a}" is not a repo path, or path:line`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Spec 1.5 "Lane editing": `write_run_file` refuses a lane whose header values are present but wrong (a Kind
|
|
113
|
+
* or Difficulty the catalog does not know, an Owns path that leaves the repo, a malformed After: or Allow:),
|
|
114
|
+
* so a typo is caught when the lane is written, not at preflight. A missing line is left to routing.
|
|
115
|
+
*/
|
|
116
|
+
export function assertLaneValues(text: string, where: string): LaneHeader {
|
|
117
|
+
let h: LaneHeader;
|
|
118
|
+
try {
|
|
119
|
+
h = parseLaneHeader(text);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new CatherdError("E_LANE_INVALID", `${where}: ${(e as Error).message}`, {
|
|
122
|
+
fix: "write Owns: paths like src/foo/ or src/bar.ts",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const problems: string[] = [];
|
|
126
|
+
const kind = field(text, "kind");
|
|
127
|
+
const difficulty = field(text, "difficulty");
|
|
128
|
+
if (kind !== null && h.kind === null) problems.push(`Kind "${unquote(kind)}" is not one the catalog knows`);
|
|
129
|
+
if (difficulty !== null && h.difficulty === null)
|
|
130
|
+
problems.push(`Difficulty "${unquote(difficulty)}" is not one the catalog knows`);
|
|
131
|
+
problems.push(...orderProblems(h));
|
|
132
|
+
if (problems.length)
|
|
133
|
+
throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, {
|
|
134
|
+
fix: `${LANE_HEADER_FIX}; After: M1.L1, M1.L2; Allow: path or path:line`,
|
|
135
|
+
});
|
|
136
|
+
return h;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether every line a failing check printed is a `path:line` hit its lane allows: an absence check (a grep
|
|
141
|
+
* that must find nothing) whose only hits are the lane's declared exceptions has passed.
|
|
142
|
+
*/
|
|
143
|
+
export function onlyAllowedHits(lines: string[], allow: string[]): boolean {
|
|
144
|
+
const hits = lines.map((l) => /^([^\s:]+):(\d+)(?::|$)/.exec(l.trim()));
|
|
145
|
+
if (allow.length === 0 || hits.length === 0 || hits.some((m) => !m)) return false;
|
|
146
|
+
return hits.every((m) =>
|
|
147
|
+
allow.some((entry) => {
|
|
148
|
+
const [, path, line] = ALLOW_ENTRY.exec(entry) ?? [];
|
|
149
|
+
if (!path) return false;
|
|
150
|
+
const file = (m as RegExpExecArray)[1] as string;
|
|
151
|
+
const at = (m as RegExpExecArray)[2];
|
|
152
|
+
const inPath = file === path || (path.endsWith("/") && file.startsWith(path));
|
|
153
|
+
return inPath && (line === undefined || line === at);
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
63
158
|
/** The fix every lane-header refusal carries: the values the catalog knows. */
|
|
64
159
|
export const LANE_HEADER_FIX = `write the lane's header lines as Kind: ${KINDS.join("|")} and Difficulty: ${DIFFICULTIES.join("|")}`;
|
|
65
160
|
|
|
@@ -78,6 +173,7 @@ export function assertLaneHeader(text: string, where: string): LaneHeader {
|
|
|
78
173
|
};
|
|
79
174
|
check("Kind", field(text, "kind"), h.kind !== null);
|
|
80
175
|
check("Difficulty", field(text, "difficulty"), h.difficulty !== null);
|
|
176
|
+
problems.push(...orderProblems(h));
|
|
81
177
|
if (problems.length)
|
|
82
178
|
throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, { fix: LANE_HEADER_FIX });
|
|
83
179
|
return h;
|
package/src/domain/notice.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface Notice {
|
|
|
17
17
|
role: string;
|
|
18
18
|
/** the lane it worked, when it had one: the multi-role preview names it */
|
|
19
19
|
lane: string | null;
|
|
20
|
+
/** the role's thread, so a fix round resumes it without reading runs.jsonl; null before the CLI named one */
|
|
21
|
+
thread: string | null;
|
|
20
22
|
rung: string;
|
|
21
23
|
/** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
|
|
22
24
|
status: string;
|
|
@@ -34,15 +36,19 @@ export const REPLY_CAP = 2_000;
|
|
|
34
36
|
|
|
35
37
|
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
36
38
|
|
|
39
|
+
/** `thread: <id>`, or `thread: none` before the CLI named one: every message's first line carries it. */
|
|
40
|
+
const threadWord = (n: Notice): string => `thread: ${n.thread ?? "none"}`;
|
|
41
|
+
|
|
37
42
|
/** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
|
|
38
43
|
export function noticeHeader(n: Notice): string {
|
|
39
44
|
const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
|
|
40
|
-
if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
|
|
45
|
+
if (n.kind === "stalled") return `${head} · running ${n.secs}s · ${threadWord(n)}`;
|
|
41
46
|
return [
|
|
42
47
|
head,
|
|
43
48
|
n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
|
|
44
49
|
`${n.secs}s`,
|
|
45
50
|
plural(n.changedOwned, "owned file changed", "owned files changed"),
|
|
51
|
+
threadWord(n),
|
|
46
52
|
].join(" · ");
|
|
47
53
|
}
|
|
48
54
|
|
|
@@ -78,7 +84,8 @@ function previewOf(ns: Notice[]): string {
|
|
|
78
84
|
? `${ns.length} roles finished`
|
|
79
85
|
: `${plural(ns.length, "role", "roles")} to look at`;
|
|
80
86
|
const each = ns.map(
|
|
81
|
-
(n) =>
|
|
87
|
+
(n) =>
|
|
88
|
+
`${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]} (${threadWord(n)})`,
|
|
82
89
|
);
|
|
83
90
|
return `catherd · ${where} · ${what}: ${each.join(", ")}`;
|
|
84
91
|
}
|