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/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 &&
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type OrchestrationHost = "claude-code" | "codex" | "unknown";
|
|
2
|
+
export type KnownHost = Exclude<OrchestrationHost, "unknown">;
|
|
3
|
+
export interface HostSessionRef {
|
|
4
|
+
host: KnownHost;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
hostSessionId: string | null;
|
|
7
|
+
name: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface HostContext {
|
|
10
|
+
host: OrchestrationHost;
|
|
11
|
+
session: HostSessionRef | null;
|
|
12
|
+
conflict: string | null;
|
|
13
|
+
}
|
|
14
|
+
export function sessionKey(s: Pick<HostSessionRef, "host" | "sessionId">): string {
|
|
15
|
+
// a Codex thread is a UUID, whatever its casing: an owner stored before normalization still matches
|
|
16
|
+
return JSON.stringify([s.host, s.host === "codex" ? s.sessionId.toLowerCase() : s.sessionId]);
|
|
17
|
+
}
|
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
|
@@ -9,6 +9,7 @@ export type NoticePriority = "later" | "next";
|
|
|
9
9
|
/** One role's news: it finished (`record`), or an event while it runs (`stall`). */
|
|
10
10
|
export interface Notice {
|
|
11
11
|
kind: "finished" | "stalled";
|
|
12
|
+
eventId: string;
|
|
12
13
|
runId: string;
|
|
13
14
|
runTitle: string;
|
|
14
15
|
dispatchId: string;
|
|
@@ -16,6 +17,8 @@ export interface Notice {
|
|
|
16
17
|
role: string;
|
|
17
18
|
/** the lane it worked, when it had one: the multi-role preview names it */
|
|
18
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;
|
|
19
22
|
rung: string;
|
|
20
23
|
/** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
|
|
21
24
|
status: string;
|
|
@@ -33,15 +36,19 @@ export const REPLY_CAP = 2_000;
|
|
|
33
36
|
|
|
34
37
|
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
35
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
|
+
|
|
36
42
|
/** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
|
|
37
43
|
export function noticeHeader(n: Notice): string {
|
|
38
44
|
const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
|
|
39
|
-
if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
|
|
45
|
+
if (n.kind === "stalled") return `${head} · running ${n.secs}s · ${threadWord(n)}`;
|
|
40
46
|
return [
|
|
41
47
|
head,
|
|
42
48
|
n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
|
|
43
49
|
`${n.secs}s`,
|
|
44
50
|
plural(n.changedOwned, "owned file changed", "owned files changed"),
|
|
51
|
+
threadWord(n),
|
|
45
52
|
].join(" · ");
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -62,9 +69,11 @@ const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2
|
|
|
62
69
|
|
|
63
70
|
function block(n: Notice): string {
|
|
64
71
|
const call = `run: "${n.runId}", name: "${n.name}"`;
|
|
65
|
-
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
|
|
72
|
+
if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})\nEvent: ${n.eventId}`;
|
|
66
73
|
const reply = capNoticeReply(n.reply);
|
|
67
|
-
return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join(
|
|
74
|
+
return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`, `Event: ${n.eventId}`].join(
|
|
75
|
+
"\n",
|
|
76
|
+
);
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
/** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
|
|
@@ -75,7 +84,8 @@ function previewOf(ns: Notice[]): string {
|
|
|
75
84
|
? `${ns.length} roles finished`
|
|
76
85
|
: `${plural(ns.length, "role", "roles")} to look at`;
|
|
77
86
|
const each = ns.map(
|
|
78
|
-
(n) =>
|
|
87
|
+
(n) =>
|
|
88
|
+
`${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]} (${threadWord(n)})`,
|
|
79
89
|
);
|
|
80
90
|
return `catherd · ${where} · ${what}: ${each.join(", ")}`;
|
|
81
91
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "./host.ts";
|
|
1
2
|
import {
|
|
2
3
|
type Catalog,
|
|
3
4
|
type Dim,
|
|
@@ -20,10 +21,25 @@ import { tryParseRung } from "./ids.ts";
|
|
|
20
21
|
import { DIFFICULTIES, KINDS } from "./lane.ts";
|
|
21
22
|
import { type Profile, type ProfileDoc, unknownValues } from "./profile.ts";
|
|
22
23
|
import { DEFAULT_ACCESS, ROLES, type Role } from "./roles.ts";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type Candidate,
|
|
26
|
+
candidates,
|
|
27
|
+
clearsBar,
|
|
28
|
+
defaultLadder,
|
|
29
|
+
type RoutingProfile,
|
|
30
|
+
select,
|
|
31
|
+
} from "./select.ts";
|
|
24
32
|
|
|
25
33
|
export { quotaOf };
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `reach`: also warn about the kinds and difficulties no worker rung clears (spec 1.5 plan 24), which only an
|
|
37
|
+
* explicit `profile validate` asks for.
|
|
38
|
+
*/
|
|
39
|
+
export interface ValidateOptions {
|
|
40
|
+
reach?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
/** One finding of `validate`: where in the profile, what is wrong, and the action that fixes it. */
|
|
28
44
|
export interface Issue {
|
|
29
45
|
path: string;
|
|
@@ -113,7 +129,8 @@ export const routingProfileOf = (p: Profile, role: Role): RoutingProfile => ({
|
|
|
113
129
|
/**
|
|
114
130
|
* Whether a rung's scores are catherd's guess: borrowed through a treat-like, filled by an inferred stand-in
|
|
115
131
|
* (spec 1.2 §6.1), or only `inferred` ones. `note` says what a treat-like lends: "scores borrowed from X"
|
|
116
|
-
* when
|
|
132
|
+
* when every value is borrowed, else the dimensions it borrows (a rung a sync scored on some); and (1.2
|
|
133
|
+
* minor) what each inferred stand-in lends: "honesty inferred from Y".
|
|
117
134
|
*/
|
|
118
135
|
export function inferredScores(
|
|
119
136
|
c: Catalog,
|
|
@@ -124,9 +141,17 @@ export function inferredScores(
|
|
|
124
141
|
const records = Object.values(s.records);
|
|
125
142
|
const guessed =
|
|
126
143
|
s.via !== null || s.inferred.length > 0 || records.every((r) => r.confidence === "inferred");
|
|
127
|
-
const all = s.borrowed.length
|
|
128
|
-
const
|
|
129
|
-
|
|
144
|
+
const all = s.borrowed.length === records.length;
|
|
145
|
+
const byStandIn = new Map<string, Dim[]>();
|
|
146
|
+
for (const d of s.inferred) {
|
|
147
|
+
const like = s.standIns[d] as string;
|
|
148
|
+
byStandIn.set(like, [...(byStandIn.get(like) ?? []), d]);
|
|
149
|
+
}
|
|
150
|
+
const parts = [
|
|
151
|
+
...(s.via ? [`${all ? "scores" : s.borrowed.join(", ")} borrowed from ${s.via}`] : []),
|
|
152
|
+
...[...byStandIn].map(([like, dims]) => `${dims.join(", ")} inferred from ${like}`),
|
|
153
|
+
];
|
|
154
|
+
return { inferred: guessed, via: s.via, note: parts.length ? parts.join("; ") : null };
|
|
130
155
|
}
|
|
131
156
|
|
|
132
157
|
/**
|
|
@@ -145,6 +170,8 @@ export function validateProfile(
|
|
|
145
170
|
c: Catalog,
|
|
146
171
|
backends: readonly string[],
|
|
147
172
|
doc?: ProfileDoc,
|
|
173
|
+
host: OrchestrationHost = "unknown",
|
|
174
|
+
o: ValidateOptions = {},
|
|
148
175
|
): Validation {
|
|
149
176
|
const errors: Issue[] = [];
|
|
150
177
|
const warnings: Issue[] = [];
|
|
@@ -170,7 +197,16 @@ export function validateProfile(
|
|
|
170
197
|
message: `${role} runs ${rc.access}; catherd's default for it is ${DEFAULT_ACCESS[role]}`,
|
|
171
198
|
});
|
|
172
199
|
if (!rc.enabled) continue;
|
|
200
|
+
// spec §5: network: false takes a workspace-write role's network grants; read-only and full have none to take
|
|
201
|
+
if (rc.network === false && rc.access !== "workspace-write")
|
|
202
|
+
warnings.push({
|
|
203
|
+
path: `${at}.network`,
|
|
204
|
+
message: `network: false does nothing here: it applies to workspace-write roles, and ${role} runs ${rc.access}`,
|
|
205
|
+
fix: `catherd profile set ${at}.network null`,
|
|
206
|
+
});
|
|
173
207
|
for (const rung of rc.rungs) {
|
|
208
|
+
const native = nativeClaudeIssue(rung, host, `${at}.rungs`);
|
|
209
|
+
if (native) errors.push(native);
|
|
174
210
|
const r = tryParseRung(rung);
|
|
175
211
|
if (!r) {
|
|
176
212
|
errors.push({
|
|
@@ -214,7 +250,7 @@ export function validateProfile(
|
|
|
214
250
|
if (!scoresOf(c, info.canonical))
|
|
215
251
|
warnings.push({
|
|
216
252
|
path: `${at}.rungs`,
|
|
217
|
-
message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it`,
|
|
253
|
+
message: `${rung} is unscored and no rung is near enough to stand in for it: routing skips it${rung === rc.defaultRung ? `; it is the ${role}'s default rung, so ${defaultFallback(c, p, role)}` : ""}`,
|
|
218
254
|
fix: TREAT_LIKE_FIX(rung),
|
|
219
255
|
});
|
|
220
256
|
}
|
|
@@ -253,27 +289,53 @@ export function validateProfile(
|
|
|
253
289
|
path: `${at}.rungs`,
|
|
254
290
|
message: `${x.rung} clears no routing bar, so a lane starts on it only as the role's default rung and never climbs onto it`,
|
|
255
291
|
});
|
|
256
|
-
// spec 1.
|
|
257
|
-
//
|
|
258
|
-
//
|
|
292
|
+
// spec 1.5 plan 24: every kind and difficulty no worker rung clears, in one warning; those lanes start at the
|
|
293
|
+
// default rung (or an easier difficulty's start) and climb only onto rungs at least as strong. Only an
|
|
294
|
+
// explicit validate says it (Ruling 3): the shipped bars put logic and hard above every Sol rung, the
|
|
295
|
+
// owner's call, so every save and doctor would repeat it
|
|
296
|
+
if (o.reach && role === "worker" && usable.length > 1) {
|
|
297
|
+
const unreached = unreachable(c, usable);
|
|
298
|
+
if (unreached.length)
|
|
299
|
+
warnings.push({
|
|
300
|
+
path: `${at}.rungs`,
|
|
301
|
+
message: `no worker rung clears ${unreached.join("; ")}: those lanes start at the default rung and climb only onto rungs at least as strong (route names the closest)`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// spec 1.5 plan 24: a quota whose rungs never start a lane sits idle but for climbs (the identity run's Go).
|
|
305
|
+
// Only the worker routes lanes and climbs (Ruling 9); another role routes lane-less to one rung, so a rung
|
|
306
|
+
// on a second quota is a deliberate spare there, not an idle quota (final review Important 4, Ruling 8)
|
|
259
307
|
if (role === "worker" && usable.length > 1) {
|
|
260
|
-
const
|
|
261
|
-
|
|
308
|
+
const idle = idleQuotas(c, p, role, usable);
|
|
309
|
+
for (const [q, rungs] of idle) {
|
|
310
|
+
// the billing key, not the quota, is what billing is set on (`claude:` and `claude-code:` share one)
|
|
311
|
+
const keys = [...new Set(usable.filter((x) => rungs.includes(x.rung)).map((x) => x.info.key))];
|
|
262
312
|
warnings.push({
|
|
263
313
|
path: `${at}.rungs`,
|
|
264
|
-
message:
|
|
314
|
+
message: `${q} never starts a ${role} lane: ${rungs.join(", ")} ${rungs.length > 1 ? "run" : "runs"} only on a climb, since another rung starts every lane`,
|
|
315
|
+
fix: `check ${keys.map((k) => `billing.${k}`).join(" and ")} (a metered rung starts only where nothing paid from a plan clears the bar), or order ${at}.rungs so its rungs come first among equals`,
|
|
265
316
|
});
|
|
317
|
+
}
|
|
266
318
|
}
|
|
267
319
|
}
|
|
268
320
|
|
|
269
321
|
const ladders = new Set(ROLES.filter((r) => p.roles[r].enabled).flatMap((r) => p.roles[r].rungs));
|
|
270
|
-
// the stand-ins
|
|
322
|
+
// the stand-ins the fixes below may suggest: on a backend catherd runs and the profile already names (a
|
|
323
|
+
// ladder or a failover entry), never one this machine may not have (Cursor on a machine without it); a
|
|
324
|
+
// native claude: rung names claude-code too, the same Claude Code CLI
|
|
325
|
+
const named = new Set(
|
|
326
|
+
[...ladders, ...Object.keys(p.failover), ...Object.values(p.failover)].flatMap((x) => {
|
|
327
|
+
const b = tryParseRung(x)?.backend;
|
|
328
|
+
return b === undefined ? [] : b === "claude" ? [b, "claude-code"] : [b];
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
271
331
|
const pool = catalogRungs(c).filter((x) => {
|
|
272
332
|
const r = tryParseRung(x);
|
|
273
|
-
return r !== null && backends.includes(r.backend);
|
|
333
|
+
return r !== null && backends.includes(r.backend) && named.has(r.backend);
|
|
274
334
|
});
|
|
275
335
|
for (const [from, to] of Object.entries(p.failover)) {
|
|
276
336
|
const at = `failover.${from}`;
|
|
337
|
+
const native = ladders.has(from) ? nativeClaudeIssue(to, host, at) : null;
|
|
338
|
+
if (native) errors.push(native);
|
|
277
339
|
const a = tryParseRung(from);
|
|
278
340
|
const b = tryParseRung(to);
|
|
279
341
|
if (!a || !b) {
|
|
@@ -288,7 +350,8 @@ export function validateProfile(
|
|
|
288
350
|
errors.push({ path: at, message: `stand-in ${to}: catherd cannot run ${b.backend} yet` });
|
|
289
351
|
continue;
|
|
290
352
|
}
|
|
291
|
-
|
|
353
|
+
const unscored = !scoresOf(c, rungInfo(c, to).canonical);
|
|
354
|
+
if (unscored)
|
|
292
355
|
warnings.push({
|
|
293
356
|
path: at,
|
|
294
357
|
message: `stand-in ${to} is unscored and no rung is near enough to stand in for it`,
|
|
@@ -301,7 +364,8 @@ export function validateProfile(
|
|
|
301
364
|
fix: "name a stand-in on another backend or plan",
|
|
302
365
|
});
|
|
303
366
|
const ranked = rankStandIns(c, p.billing, from, pool);
|
|
304
|
-
|
|
367
|
+
// an unscored stand-in has its own warning above: no "downgrade" on every bar dim besides
|
|
368
|
+
const down = unscored ? [] : downgradeDims(c, from, to);
|
|
305
369
|
if (down.length)
|
|
306
370
|
warnings.push({
|
|
307
371
|
path: at,
|
|
@@ -327,3 +391,52 @@ export function validateProfile(
|
|
|
327
391
|
warnings.push({ path: x.path, message: standInMessage(x), fix: SUGGEST_FIX(x.rungs[0] as string) });
|
|
328
392
|
return { errors, warnings };
|
|
329
393
|
}
|
|
394
|
+
|
|
395
|
+
/** The role's default rung when it is unscored: what routing starts on instead. */
|
|
396
|
+
function defaultFallback(c: Catalog, p: Profile, role: Role): string {
|
|
397
|
+
try {
|
|
398
|
+
return `routing falls back to ${defaultLadder(c, routingProfileOf(p, role), role).rung}`;
|
|
399
|
+
} catch {
|
|
400
|
+
return "routing has no rung to fall back to";
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `repo_code logic, hard` per kind: the kinds and difficulties no rung of `usable` clears. */
|
|
405
|
+
function unreachable(c: Catalog, usable: Candidate[]): string[] {
|
|
406
|
+
return KINDS.flatMap((k) => {
|
|
407
|
+
const ds = DIFFICULTIES.filter((d) => !usable.some((x) => clearsBar(c, x, k, d)));
|
|
408
|
+
return ds.length ? [`${k} ${ds.join(", ")}`] : [];
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Spec 1.5 plan 24: each quota of the role's usable rungs that no kind and difficulty (nor a lane-less route)
|
|
414
|
+
* ever starts on, whichever quota the run has used least, with its rungs.
|
|
415
|
+
*/
|
|
416
|
+
function idleQuotas(c: Catalog, p: Profile, role: Role, usable: Candidate[]): [string, string[]][] {
|
|
417
|
+
const quotas = [...new Set(usable.map((x) => quotaOf(x.info.parsed)))];
|
|
418
|
+
if (quotas.length < 2) return [];
|
|
419
|
+
const starts = new Set<string>();
|
|
420
|
+
// every quota gets its turn as the least used, so a tie it could win counts as a start
|
|
421
|
+
for (const fresh of [null, ...quotas]) {
|
|
422
|
+
const usage = Object.fromEntries(quotas.map((q) => [q, q === fresh ? 0 : 1]));
|
|
423
|
+
const rp = { ...routingProfileOf(p, role), usage };
|
|
424
|
+
starts.add(defaultLadder(c, rp, role).rung);
|
|
425
|
+
for (const k of KINDS) for (const d of DIFFICULTIES) starts.add(select(c, rp, role, k, d).rung);
|
|
426
|
+
}
|
|
427
|
+
const started = new Set(usable.filter((x) => starts.has(x.rung)).map((x) => quotaOf(x.info.parsed)));
|
|
428
|
+
return quotas
|
|
429
|
+
.filter((q) => !started.has(q))
|
|
430
|
+
.map((q) => [q, usable.filter((x) => quotaOf(x.info.parsed) === q).map((x) => x.rung)]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function nativeClaudeIssue(rung: string, host: OrchestrationHost, path: string): Issue | null {
|
|
434
|
+
const r = tryParseRung(rung);
|
|
435
|
+
return host !== "claude-code" && r?.backend === "claude"
|
|
436
|
+
? {
|
|
437
|
+
path,
|
|
438
|
+
message: `${rung}: native Claude subagents require the claude-code orchestration host`,
|
|
439
|
+
fix: `use claude-code:${r.model}#${r.effort} for headless execution, or --host claude-code`,
|
|
440
|
+
}
|
|
441
|
+
: null;
|
|
442
|
+
}
|