catherd-cli 0.2.1 → 1.1.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.
Files changed (207) hide show
  1. package/README.md +141 -33
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +2 -2
  9. package/plugin/bin/catherd-mcp +30 -0
  10. package/plugin/commands/catherd.md +2 -2
  11. package/plugin/skills/catherd/SKILL.md +160 -97
  12. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  13. package/src/adapters/access.ts +63 -0
  14. package/src/adapters/all.ts +8 -0
  15. package/src/adapters/backend.ts +159 -0
  16. package/src/adapters/claude-code/events.ts +86 -0
  17. package/src/adapters/claude-code/index.ts +317 -0
  18. package/src/adapters/claude-code/models-api.ts +65 -0
  19. package/src/adapters/claude-code/models.ts +26 -0
  20. package/src/adapters/cli.ts +74 -0
  21. package/src/adapters/codex/events.ts +65 -0
  22. package/src/adapters/codex/home.ts +47 -0
  23. package/src/adapters/codex/index.ts +282 -0
  24. package/src/adapters/discovery.ts +87 -0
  25. package/src/adapters/opencode/agents.ts +84 -0
  26. package/src/adapters/opencode/events.ts +109 -0
  27. package/src/adapters/opencode/index.ts +333 -0
  28. package/src/adapters/registry.ts +14 -0
  29. package/src/cli.ts +159 -19
  30. package/src/domain/agents.ts +60 -0
  31. package/src/domain/budget.ts +45 -0
  32. package/src/domain/catalog.ts +262 -0
  33. package/src/domain/changes.ts +52 -0
  34. package/src/domain/cost.ts +89 -0
  35. package/src/domain/errors.ts +60 -0
  36. package/src/domain/failover.ts +119 -0
  37. package/src/domain/hints.ts +22 -0
  38. package/src/domain/ids.ts +97 -0
  39. package/src/domain/jev.ts +283 -0
  40. package/src/domain/lane.ts +92 -0
  41. package/src/domain/notice.ts +95 -0
  42. package/src/domain/profile-rules.ts +239 -0
  43. package/src/domain/profile.ts +389 -0
  44. package/src/domain/record.ts +86 -0
  45. package/src/{profile → domain}/role-prompts.ts +75 -27
  46. package/src/domain/roles.ts +25 -0
  47. package/src/domain/route.ts +126 -0
  48. package/src/domain/runtime.ts +21 -0
  49. package/src/domain/sanitize.ts +29 -0
  50. package/src/domain/secrets.ts +57 -0
  51. package/src/domain/select.ts +136 -0
  52. package/src/domain/state.ts +49 -0
  53. package/src/domain/util.ts +18 -0
  54. package/src/entry/capture-fixtures-command.ts +57 -0
  55. package/src/entry/catalog-command.ts +106 -0
  56. package/src/entry/cli-kit.ts +36 -0
  57. package/src/entry/deps.ts +17 -0
  58. package/src/entry/doctor-command.ts +51 -0
  59. package/src/entry/glyphs.ts +34 -0
  60. package/src/entry/init-command.ts +182 -0
  61. package/src/entry/lock-command.ts +123 -0
  62. package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
  63. package/src/entry/mcp/dispatch-tools.ts +52 -0
  64. package/src/entry/mcp/handshake.ts +58 -0
  65. package/src/entry/mcp/lane-tools.ts +84 -0
  66. package/src/entry/mcp/protocol-tools.ts +57 -0
  67. package/src/entry/mcp/result.ts +51 -0
  68. package/src/entry/mcp/run-tools.ts +145 -0
  69. package/src/entry/mcp/server.ts +88 -0
  70. package/src/entry/mcp/setup-tools.ts +81 -0
  71. package/src/entry/profile-command.ts +318 -0
  72. package/src/entry/prompt.ts +103 -0
  73. package/src/entry/runs-command.ts +260 -0
  74. package/src/entry/supervise-bin.ts +18 -0
  75. package/src/entry/supervise-command.ts +37 -0
  76. package/src/entry/tui/commands.ts +545 -0
  77. package/src/entry/tui/effects.ts +312 -0
  78. package/src/entry/tui/fixtures.ts +585 -0
  79. package/src/entry/tui/profile-edits.ts +138 -0
  80. package/src/entry/tui/profile-tree.ts +420 -0
  81. package/src/entry/tui/providers/app.tsx +162 -0
  82. package/src/entry/tui/providers/boundary.tsx +57 -0
  83. package/src/entry/tui/providers/data.tsx +160 -0
  84. package/src/entry/tui/providers/keymap.tsx +166 -0
  85. package/src/entry/tui/providers/root.tsx +45 -0
  86. package/src/entry/tui/providers/theme.tsx +17 -0
  87. package/src/entry/tui/providers/toast.tsx +77 -0
  88. package/src/entry/tui/run.tsx +98 -0
  89. package/src/entry/tui/state.ts +356 -0
  90. package/src/entry/tui/stories.ts +107 -0
  91. package/src/entry/tui/text.ts +130 -0
  92. package/src/entry/tui/theme.ts +140 -0
  93. package/src/entry/tui/views/app.tsx +208 -0
  94. package/src/entry/tui/views/dialogs.tsx +41 -0
  95. package/src/entry/tui/views/profile-actions.ts +321 -0
  96. package/src/entry/tui/views/profiles.tsx +440 -0
  97. package/src/entry/tui/views/runs.tsx +573 -0
  98. package/src/entry/tui/views/save-dialog.tsx +332 -0
  99. package/src/entry/tui/views/status.tsx +209 -0
  100. package/src/entry/tui/widgets/chrome.tsx +114 -0
  101. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  102. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  103. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  104. package/src/entry/tui/widgets/dialog.tsx +68 -0
  105. package/src/entry/tui/widgets/line.tsx +92 -0
  106. package/src/entry/tui/widgets/list.tsx +197 -0
  107. package/src/entry/tui/widgets/spinner.tsx +49 -0
  108. package/src/entry/tui/widgets/toast.tsx +62 -0
  109. package/src/infra/assets.ts +4 -0
  110. package/src/infra/claude-session.ts +98 -0
  111. package/src/infra/dispatch-dir.ts +286 -0
  112. package/src/infra/env.ts +67 -0
  113. package/src/infra/filelock.ts +159 -0
  114. package/src/infra/git.ts +91 -0
  115. package/src/infra/heavy-lock.ts +36 -0
  116. package/src/infra/jev-client.ts +162 -0
  117. package/src/infra/launch.ts +25 -0
  118. package/src/infra/log.ts +84 -0
  119. package/src/infra/paths.ts +38 -0
  120. package/src/infra/peer-inbox.ts +92 -0
  121. package/src/infra/proc.ts +110 -0
  122. package/src/infra/store.ts +229 -0
  123. package/src/infra/supervisor.ts +287 -0
  124. package/src/infra/version.ts +6 -0
  125. package/src/services/admission.ts +303 -0
  126. package/src/services/agent-links.ts +139 -0
  127. package/src/services/backends.ts +44 -0
  128. package/src/services/budget.ts +47 -0
  129. package/src/services/capture.ts +233 -0
  130. package/src/services/catalog-service.ts +382 -0
  131. package/src/services/dispatch-service.ts +620 -0
  132. package/src/services/dispatches.ts +174 -0
  133. package/src/services/doctor-access.ts +231 -0
  134. package/src/services/doctor-backends.ts +170 -0
  135. package/src/services/doctor-checks.ts +148 -0
  136. package/src/services/doctor-push.ts +109 -0
  137. package/src/services/doctor.ts +338 -0
  138. package/src/services/finalize.ts +377 -0
  139. package/src/services/gate-service.ts +288 -0
  140. package/src/services/global-install.ts +99 -0
  141. package/src/services/jev-service.ts +183 -0
  142. package/src/services/lane-service.ts +390 -0
  143. package/src/services/milestones.ts +208 -0
  144. package/src/services/notifier.ts +296 -0
  145. package/src/services/peek.ts +103 -0
  146. package/src/services/ports.ts +135 -0
  147. package/src/services/preflight.ts +221 -0
  148. package/src/services/profile-service.ts +292 -0
  149. package/src/services/profile-store.ts +141 -0
  150. package/src/services/protocol.ts +212 -0
  151. package/src/services/questions.ts +102 -0
  152. package/src/services/reconcile.ts +130 -0
  153. package/src/services/reentry.ts +22 -0
  154. package/src/services/routing-service.ts +190 -0
  155. package/src/services/run-debug.ts +79 -0
  156. package/src/services/run-service.ts +194 -0
  157. package/src/services/run-store.ts +334 -0
  158. package/src/services/runs-page.ts +254 -0
  159. package/src/services/session-view.ts +152 -0
  160. package/src/services/sessions.ts +92 -0
  161. package/src/services/setup.ts +87 -0
  162. package/src/services/state.ts +111 -0
  163. package/src/services/summary.ts +241 -0
  164. package/catalog/catalog.json +0 -112
  165. package/catalog/models-dev.json +0 -56453
  166. package/src/core/codex-events.ts +0 -49
  167. package/src/core/codex.ts +0 -166
  168. package/src/core/harness.ts +0 -89
  169. package/src/core/lanes.ts +0 -67
  170. package/src/core/lock.ts +0 -111
  171. package/src/core/opencode.ts +0 -207
  172. package/src/core/reconcile.ts +0 -26
  173. package/src/core/reply.ts +0 -54
  174. package/src/core/runstore.ts +0 -163
  175. package/src/core/spawn.ts +0 -89
  176. package/src/core/status.ts +0 -193
  177. package/src/files.ts +0 -19
  178. package/src/mcp/dispatch.ts +0 -154
  179. package/src/mcp/lane-tools.ts +0 -200
  180. package/src/mcp/out.ts +0 -6
  181. package/src/mcp/preflight.ts +0 -84
  182. package/src/mcp/run-tools.ts +0 -121
  183. package/src/mcp/runs.ts +0 -118
  184. package/src/mcp/server.ts +0 -26
  185. package/src/mcp/setup-tools.ts +0 -175
  186. package/src/paths.ts +0 -20
  187. package/src/profile/agents.ts +0 -130
  188. package/src/profile/profile.ts +0 -228
  189. package/src/routing/catalog.ts +0 -227
  190. package/src/routing/commands.ts +0 -22
  191. package/src/routing/jev.ts +0 -185
  192. package/src/routing/route.ts +0 -155
  193. package/src/routing/select.ts +0 -90
  194. package/src/tui/backends.ts +0 -57
  195. package/src/tui/commands.ts +0 -53
  196. package/src/tui/dashboard.tsx +0 -245
  197. package/src/tui/editor.tsx +0 -214
  198. package/src/tui/init.tsx +0 -208
  199. package/src/tui/matrix-model.ts +0 -279
  200. package/src/tui/matrix.tsx +0 -512
  201. package/src/tui/profiles.ts +0 -38
  202. package/src/tui/theme.ts +0 -177
  203. package/src/tui/ui.tsx +0 -267
  204. package/src/tui/watch-model.ts +0 -83
  205. package/src/tui/watch.tsx +0 -236
  206. package/src/types.ts +0 -184
  207. package/src/version.ts +0 -7
@@ -0,0 +1,97 @@
1
+ import { CatherdError } from "./errors.ts";
2
+
3
+ /** Run, role, lane and dispatch ids end up in file names: no separator, no leading dot or dash. */
4
+ export const ID_PATTERN = /^[A-Za-z0-9][\w.-]{0,127}$/;
5
+
6
+ export function assertId(kind: string, value: string): string {
7
+ if (!ID_PATTERN.test(value) || value.includes(".."))
8
+ throw new CatherdError("E_ADMIT_ID", `bad ${kind} id "${value}"`, {
9
+ fix: "use letters, digits, '.', '_' and '-', starting with a letter or digit",
10
+ });
11
+ return value;
12
+ }
13
+
14
+ /** The backends that run as a process through a `BackendAdapter` (cursor and grok: plan 8). */
15
+ export const ADAPTER_IDS = ["codex", "claude-code", "opencode", "cursor", "grok"] as const;
16
+
17
+ /** `claude` is the native Claude Code subagent path (no process); the rest are adapters. */
18
+ const RUNG_BACKENDS = [...ADAPTER_IDS, "claude"] as const;
19
+ type RungBackend = (typeof RUNG_BACKENDS)[number];
20
+
21
+ export interface Rung {
22
+ backend: RungBackend;
23
+ model: string;
24
+ effort: string;
25
+ }
26
+
27
+ /** A model id or effort starts with a letter or digit and holds no space, so it can never read as a flag. */
28
+ const MODEL = /^[A-Za-z0-9][^\s#]*$/;
29
+ const EFFORT = /^[A-Za-z0-9][\w-]*$/;
30
+
31
+ export function parseRung(s: string): Rung {
32
+ const colon = s.indexOf(":");
33
+ const hash = s.lastIndexOf("#");
34
+ const backend = s.slice(0, Math.max(colon, 0));
35
+ const model = s.slice(colon + 1, hash);
36
+ const effort = s.slice(hash + 1);
37
+ if (
38
+ colon < 1 ||
39
+ hash <= colon + 1 ||
40
+ !MODEL.test(model) ||
41
+ !EFFORT.test(effort) ||
42
+ !(RUNG_BACKENDS as readonly string[]).includes(backend)
43
+ )
44
+ throw new CatherdError("E_ADMIT_RUNG", `bad rung "${s}"`, {
45
+ fix: "write it as <backend>:<model>#<effort>",
46
+ });
47
+ return { backend: backend as RungBackend, model, effort };
48
+ }
49
+
50
+ /** `parseRung`, or null for a malformed rung (profile validation is what reports it). */
51
+ export function tryParseRung(s: string): Rung | null {
52
+ try {
53
+ return parseRung(s);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export const formatRung = (r: Rung): string => `${r.backend}:${r.model}#${r.effort}`;
60
+
61
+ const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
62
+
63
+ let lastTime = -1;
64
+ let lastRand: number[] = [];
65
+
66
+ /**
67
+ * A monotonic ULID: 10 chars of millisecond time, then 16 random, so ids sort by creation.
68
+ * Within one millisecond (or when the clock steps back) the previous time is reused and
69
+ * the random part is incremented, so ids made in one process are strictly increasing.
70
+ */
71
+ export function newDispatchId(now: number = Date.now()): string {
72
+ if (now <= lastTime && increment(lastRand)) now = lastTime;
73
+ else {
74
+ if (now <= lastTime) now = lastTime + 1;
75
+ lastRand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b % 32);
76
+ }
77
+ lastTime = now;
78
+ let t = now;
79
+ let time = "";
80
+ for (let i = 0; i < 10; i++) {
81
+ time = B32.charAt(t % 32) + time;
82
+ t = Math.floor(t / 32);
83
+ }
84
+ return time + lastRand.map((d) => B32.charAt(d)).join("");
85
+ }
86
+
87
+ /** Adds one to base32 digits in place; false when all 16 digits overflow. */
88
+ function increment(digits: number[]): boolean {
89
+ for (let i = digits.length - 1; i >= 0; i--) {
90
+ if ((digits[i] as number) < 31) {
91
+ digits[i] = (digits[i] as number) + 1;
92
+ return true;
93
+ }
94
+ digits[i] = 0;
95
+ }
96
+ return false;
97
+ }
@@ -0,0 +1,283 @@
1
+ import { z } from "zod";
2
+ import { type Difficulty, KINDS, type Kind, parseLaneHeader } from "./lane.ts";
3
+ import { scrubKeyShapes, scrubSecretAssignments } from "./secrets.ts";
4
+
5
+ // Spec §5.5 and research 2026-09-25-jev.md: Jev's three question types, catherd's question sets as
6
+ // versioned data (catalog/jev.json), the trimmed lane state, and the decision rules.
7
+
8
+ const ChoiceQ = z.object({
9
+ type: z.literal("choice"),
10
+ instructions: z.string(),
11
+ criteria: z.record(z.string(), z.string()),
12
+ });
13
+ const ScoreQ = z.object({
14
+ type: z.literal("score"),
15
+ instructions: z.string(),
16
+ criteria: z.array(z.string()).min(2).max(10),
17
+ });
18
+ const NoulQ = z.object({
19
+ type: z.literal("noul"),
20
+ instructions: z.string(),
21
+ criteria: z.object({ true: z.string(), false: z.string() }).optional(),
22
+ });
23
+ const QuestionSchema = z.discriminatedUnion("type", [ChoiceQ, ScoreQ, NoulQ]);
24
+ export type JevQuestion = z.infer<typeof QuestionSchema>;
25
+
26
+ const RouteRuleSchema = z.object({
27
+ kindMin: z.number().min(0).max(1),
28
+ trackMin: z.number().gt(0.5).max(1),
29
+ /** exactly two levels each, in order: copy then build, logic then hard */
30
+ trackA: z.array(z.string()).length(2),
31
+ trackB: z.array(z.string()).length(2),
32
+ });
33
+ export type RouteRule = z.infer<typeof RouteRuleSchema>;
34
+ const VerdictRuleSchema = z.object({ min: z.number().min(0).max(1), fallback: z.string() });
35
+ export type VerdictRule = z.infer<typeof VerdictRuleSchema>;
36
+
37
+ export const JevFileSchema = z.looseObject({
38
+ schema: z.literal(1),
39
+ model: z.string(),
40
+ sets: z.object({
41
+ "route-v2": z.object({ questions: z.record(z.string(), QuestionSchema), rule: RouteRuleSchema }),
42
+ finding: z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
43
+ "same-defect": z.object({ questions: z.record(z.string(), QuestionSchema), rule: VerdictRuleSchema }),
44
+ }),
45
+ });
46
+ export type JevFile = z.infer<typeof JevFileSchema>;
47
+ export type SetName = keyof JevFile["sets"];
48
+
49
+ /** The answers each verdict set's caller accepts; a set's `fallback` must be one of them. */
50
+ export const VERDICT_OPTIONS = {
51
+ finding: ["design", "code", "unclear"],
52
+ "same-defect": ["yes", "no"],
53
+ } as const;
54
+
55
+ /** JSON with object keys sorted at every depth, so equal data always hashes the same. */
56
+ export function canonicalJson(v: unknown): string {
57
+ if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
58
+ if (v !== null && typeof v === "object")
59
+ return `{${Object.keys(v)
60
+ .filter((k) => (v as Record<string, unknown>)[k] !== undefined)
61
+ .sort()
62
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson((v as Record<string, unknown>)[k])}`)
63
+ .join(",")}}`;
64
+ return JSON.stringify(v) ?? "null";
65
+ }
66
+
67
+ export const sha256 = (s: string): string => new Bun.CryptoHasher("sha256").update(s).digest("hex");
68
+
69
+ /** `<set>#<8 hex>` over the model, questions and rule: any change to one names a new question set. */
70
+ export function questionSetId(f: JevFile, name: SetName): string {
71
+ const set = f.sets[name];
72
+ return `${name}#${sha256(canonicalJson({ model: f.model, questions: set.questions, rule: set.rule })).slice(0, 8)}`;
73
+ }
74
+
75
+ /** The per-run cache key of one request. */
76
+ export const requestKey = (model: string, state: unknown, questions: unknown): string =>
77
+ sha256(canonicalJson({ model, state, questions }));
78
+
79
+ /**
80
+ * Key-shaped strings (domain/secrets.ts) and `key = value` secrets replaced by [secret]; the pair, a
81
+ * bearer and a URL's user keep their names.
82
+ */
83
+ export const scrubSecrets = (text: string): string =>
84
+ scrubSecretAssignments(scrubKeyShapes(text, "[secret]"), "[secret]");
85
+
86
+ /** Research §5.3: code is not what Jev judges, and long state rots its answers. */
87
+ export const BODY_MAX = 6000;
88
+ const HEADER = /^\s*[*_]*(owns|fast check|kind|difficulty)[*_]*\s*:/i;
89
+
90
+ export interface LaneState {
91
+ title: string | null;
92
+ owns: string[];
93
+ fast_check: string | null;
94
+ body: string;
95
+ }
96
+
97
+ /**
98
+ * Spec §5.5: `{ title, owns, fast_check, body }`. The body drops the header lines (so Jev never echoes
99
+ * the architect's own Kind/Difficulty), fenced code, and secrets, and is capped at BODY_MAX characters.
100
+ * Every field is scrubbed of secrets.
101
+ */
102
+ export function laneState(text: string): LaneState {
103
+ const h = parseLaneHeader(text);
104
+ const body = dropFences(text)
105
+ .split("\n")
106
+ .filter((l) => !/^#\s/.test(l) && !HEADER.test(l))
107
+ .join("\n")
108
+ .trim();
109
+ return {
110
+ title: h.title === null ? null : scrubSecrets(h.title),
111
+ owns: h.owns.map(scrubSecrets).filter(Boolean),
112
+ fast_check: h.fastCheck === null ? null : scrubSecrets(h.fastCheck),
113
+ body: capBody(scrubSecrets(body)),
114
+ };
115
+ }
116
+
117
+ /** Fenced code, indented or not, becomes "[code omitted]"; a fence left open runs to the end of the text, as Markdown renders it. */
118
+ const dropFences = (text: string): string =>
119
+ text.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*(?:\n[\s\S]*?^[ \t]*\1[^\n]*$|[\s\S]*$)/gm, "[code omitted]");
120
+
121
+ /** Capped at BODY_MAX characters, never ending the cut on half a surrogate pair. */
122
+ const capBody = (text: string): string =>
123
+ text.length > BODY_MAX ? `${text.slice(0, BODY_MAX).replace(/[\uD800-\uDBFF]$/, "")}…` : text;
124
+
125
+ /** Free text sent to Jev (a finding, a defect): no fenced code, no secrets, capped like a lane's body. */
126
+ export function scrubFree(text: string): string {
127
+ return capBody(scrubSecrets(dropFences(text).trim()));
128
+ }
129
+
130
+ const Prob = z.record(z.string(), z.number().min(0).max(1));
131
+ const AnswerSchema = z.discriminatedUnion("type", [
132
+ z.object({ type: z.literal("choice"), choice: z.string(), probabilities: Prob, confidence: z.number() }),
133
+ z.object({
134
+ type: z.literal("score"),
135
+ score: z.number(),
136
+ probabilities: Prob,
137
+ confidence: z.number(),
138
+ }),
139
+ z.object({ type: z.literal("noul"), noul: z.number().min(0).max(1) }),
140
+ ]);
141
+ type JevAnswer = z.infer<typeof AnswerSchema>;
142
+ export type JevAnswers = Record<string, JevAnswer>;
143
+
144
+ const ReplySchema = z.object({
145
+ model: z.string(),
146
+ answers: z.record(z.string(), z.unknown()),
147
+ usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }).optional(),
148
+ });
149
+
150
+ export type ParsedReply =
151
+ | {
152
+ ok: true;
153
+ model: string;
154
+ usage: { input_tokens: number; output_tokens: number } | null;
155
+ answers: JevAnswers;
156
+ }
157
+ | { ok: false; error: string };
158
+
159
+ /** Every question answered, each in its own type and within its options, or an error naming the first. */
160
+ export function parseReply(questions: Record<string, JevQuestion>, body: unknown): ParsedReply {
161
+ const r = ReplySchema.safeParse(body);
162
+ if (!r.success) return { ok: false, error: "unexpected response" };
163
+ const answers: JevAnswers = {};
164
+ for (const [id, q] of Object.entries(questions)) {
165
+ const a = AnswerSchema.safeParse(r.data.answers[id]);
166
+ if (!a.success || a.data.type !== q.type) return { ok: false, error: `no valid answer to ${id}` };
167
+ if (a.data.type === "choice" && q.type === "choice" && !Object.hasOwn(q.criteria, a.data.choice))
168
+ return { ok: false, error: `no valid answer to ${id}` };
169
+ if (
170
+ a.data.type === "score" &&
171
+ q.type === "score" &&
172
+ Object.keys(a.data.probabilities).some((l) => !/^\d+$/.test(l) || Number(l) >= q.criteria.length)
173
+ )
174
+ return { ok: false, error: `no valid answer to ${id}` };
175
+ answers[id] = a.data;
176
+ }
177
+ return { ok: true, model: r.data.model, usage: r.data.usage ?? null, answers };
178
+ }
179
+
180
+ const pOf = (a: JevAnswer | undefined, option: string): number =>
181
+ a && a.type !== "noul" ? (a.probabilities[option] ?? 0) : 0;
182
+
183
+ export interface RouteJudgement {
184
+ /** the kind, when p_max ≥ kindMin and it is one of catherd's kinds */
185
+ kind: Kind | null;
186
+ pKind: number | null;
187
+ /** "A" (copy/build) or "B" (logic/hard) on summed probabilities, null in the dead band */
188
+ track: "A" | "B" | null;
189
+ difficulty: Difficulty | null;
190
+ pA: number | null;
191
+ pB: number | null;
192
+ /** the atomic questions, logged for calibration only */
193
+ nouls: Record<string, number>;
194
+ rule: string;
195
+ }
196
+
197
+ /**
198
+ * Spec §5.5's decision rule. Confidence is `(n·p_max − 1)/(n − 1)`, so it means different things for
199
+ * different option counts; the rule reads probabilities instead: the kind at p_max ≥ kindMin, the track
200
+ * when the summed probability of its levels reaches trackMin, and neither in between (the dead band).
201
+ */
202
+ export function judgeRoute(rule: RouteRule, answers: JevAnswers): RouteJudgement {
203
+ const k = answers.kind;
204
+ const d = answers.difficulty;
205
+ const nouls: Record<string, number> = {};
206
+ for (const [id, a] of Object.entries(answers)) if (a.type === "noul") nouls[id] = a.noul;
207
+ let kind: Kind | null = null;
208
+ let pKind: number | null = null;
209
+ if (k && k.type === "choice") {
210
+ const [top, p] = Object.entries(k.probabilities).reduce((m, e) => (e[1] > m[1] ? e : m), ["", -1]);
211
+ pKind = p < 0 ? null : p;
212
+ if (p >= rule.kindMin && (KINDS as readonly string[]).includes(top)) kind = top as Kind;
213
+ }
214
+ if (!d || d.type !== "score")
215
+ return { kind, pKind, track: null, difficulty: null, pA: null, pB: null, nouls, rule: "no difficulty" };
216
+ // Rounded before comparing: 0.1 + 0.7 is 0.7999999999999999 in floating point, and must reach 0.8.
217
+ const round = (x: number) => Math.round(x * 1e6) / 1e6;
218
+ const sum = (levels: string[]) => round(levels.reduce((s, l) => s + pOf(d, l), 0));
219
+ const pA = sum(rule.trackA);
220
+ const pB = sum(rule.trackB);
221
+ if (pA >= rule.trackMin)
222
+ return {
223
+ kind,
224
+ pKind,
225
+ track: "A",
226
+ difficulty: pOf(d, rule.trackA[0] ?? "") > pOf(d, rule.trackA[1] ?? "") ? "copy" : "build",
227
+ pA,
228
+ pB,
229
+ nouls,
230
+ rule: `P(A) ${pA} ≥ ${rule.trackMin}`,
231
+ };
232
+ if (pB >= rule.trackMin)
233
+ return {
234
+ kind,
235
+ pKind,
236
+ track: "B",
237
+ difficulty: pOf(d, rule.trackB[1] ?? "") > pOf(d, rule.trackB[0] ?? "") ? "hard" : "logic",
238
+ pA,
239
+ pB,
240
+ nouls,
241
+ rule: `P(B) ${pB} ≥ ${rule.trackMin}`,
242
+ };
243
+ return {
244
+ kind,
245
+ pKind,
246
+ track: null,
247
+ difficulty: null,
248
+ pA,
249
+ pB,
250
+ nouls,
251
+ rule: `P(A) ${pA}, P(B) ${pB}: both below ${rule.trackMin}`,
252
+ };
253
+ }
254
+
255
+ export interface Verdict<T extends string> {
256
+ value: T;
257
+ /** the chosen answer's probability, when Jev answered */
258
+ probability: number | null;
259
+ /** Jev's own confidence statistic, when it answered */
260
+ confidence: number | null;
261
+ source: "jev" | "default";
262
+ }
263
+
264
+ /** Spec §5.5: `finding` and `same-defect` keep their 0.x meaning, with thresholds on probability. */
265
+ export function judgeVerdict<T extends string>(
266
+ rule: VerdictRule,
267
+ id: string,
268
+ options: readonly T[],
269
+ answers: JevAnswers | null,
270
+ ): Verdict<T> {
271
+ const a = answers?.[id];
272
+ const fallback = rule.fallback as T;
273
+ if (!a || a.type !== "choice" || !(options as readonly string[]).includes(a.choice))
274
+ return { value: fallback, probability: null, confidence: null, source: "default" };
275
+ const p = a.probabilities[a.choice] ?? 0;
276
+ const sure = p >= rule.min;
277
+ return {
278
+ value: sure ? (a.choice as T) : fallback,
279
+ probability: p,
280
+ confidence: a.confidence,
281
+ source: sure ? "jev" : "default",
282
+ };
283
+ }
@@ -0,0 +1,92 @@
1
+ import { CatherdError } from "./errors.ts";
2
+
3
+ export const KINDS = ["repo_code", "terminal", "ui", "prose", "research"] as const;
4
+ export type Kind = (typeof KINDS)[number];
5
+ export const DIFFICULTIES = ["copy", "build", "logic", "hard"] as const;
6
+ export type Difficulty = (typeof DIFFICULTIES)[number];
7
+
8
+ export interface LaneHeader {
9
+ title: string | null;
10
+ owns: string[];
11
+ fastCheck: string | null;
12
+ kind: Kind | null;
13
+ difficulty: Difficulty | null;
14
+ }
15
+
16
+ const unquote = (s: string) =>
17
+ s
18
+ .trim()
19
+ .replace(/^`+|`+$/g, "")
20
+ .trim();
21
+
22
+ /** The value of the first `Label:` line, allowing `**Label:**` and `_Label_:` emphasis. */
23
+ function field(text: string, label: string): string | null {
24
+ const re = new RegExp(`^\\s*[*_]*${label}[*_]*\\s*:[*_]*\\s*(.*)$`, "i");
25
+ for (const line of text.split("\n")) {
26
+ const m = re.exec(line);
27
+ if (m) return (m[1] ?? "").trim();
28
+ }
29
+ return null;
30
+ }
31
+
32
+ const oneOf = <T extends string>(v: string | null, options: readonly T[]): T | null =>
33
+ v !== null && (options as readonly string[]).includes(unquote(v)) ? (unquote(v) as T) : null;
34
+
35
+ /** Canonical form: no "." or empty segments, a trailing "/" kept as a directory marker. */
36
+ export function normalizeOwned(p: string): string {
37
+ const s = unquote(p);
38
+ const parts = s.split("/").filter((seg) => seg !== "" && seg !== ".");
39
+ if (s.startsWith("/") || parts.length === 0 || parts.includes(".."))
40
+ throw new CatherdError(
41
+ "E_LANE_INVALID",
42
+ `owned path "${p}" must be relative to the repo and stay inside it`,
43
+ {
44
+ fix: "write Owns: paths like src/foo/ or src/bar.ts",
45
+ },
46
+ );
47
+ return parts.join("/") + (s.endsWith("/") ? "/" : "");
48
+ }
49
+
50
+ export function parseLaneHeader(text: string): LaneHeader {
51
+ const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? null;
52
+ const owns = (field(text, "owns") ?? "").split(",").map(unquote).filter(Boolean).map(normalizeOwned);
53
+ const check = field(text, "fast check");
54
+ return {
55
+ title,
56
+ owns,
57
+ fastCheck: check ? unquote(check) || null : null,
58
+ kind: oneOf(field(text, "kind"), KINDS),
59
+ difficulty: oneOf(field(text, "difficulty"), DIFFICULTIES),
60
+ };
61
+ }
62
+
63
+ /** The fix every lane-header refusal carries: the values the catalog knows. */
64
+ export const LANE_HEADER_FIX = `write the lane's header lines as Kind: ${KINDS.join("|")} and Difficulty: ${DIFFICULTIES.join("|")}`;
65
+
66
+ /**
67
+ * Spec 1.1 §6: a lane's `Kind:` and `Difficulty:` must be values the catalog knows, so routing never falls
68
+ * back on a typo. `where` names the lane file in the message. Throws E_LANE_INVALID; returns the header.
69
+ */
70
+ export function assertLaneHeader(text: string, where: string): LaneHeader {
71
+ const h = parseLaneHeader(text);
72
+ const problems: string[] = [];
73
+ const check = (label: string, value: string | null, ok: boolean) => {
74
+ if (ok) return;
75
+ problems.push(
76
+ value === null ? `no ${label}: line` : `${label} "${unquote(value)}" is not one the catalog knows`,
77
+ );
78
+ };
79
+ check("Kind", field(text, "kind"), h.kind !== null);
80
+ check("Difficulty", field(text, "difficulty"), h.difficulty !== null);
81
+ if (problems.length)
82
+ throw new CatherdError("E_LANE_INVALID", `${where}: ${problems.join("; ")}`, { fix: LANE_HEADER_FIX });
83
+ return h;
84
+ }
85
+
86
+ const bare = (p: string) => p.replace(/\/+$/, "");
87
+ const covers = (outer: string, inner: string) => inner === outer || inner.startsWith(`${outer}/`);
88
+
89
+ /** The entries of `a` that overlap any entry of `b`; a path covers everything under it. */
90
+ export function overlaps(a: string[], b: string[]): string[] {
91
+ return a.filter((x) => b.some((y) => covers(bare(x), bare(y)) || covers(bare(y), bare(x))));
92
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Spec §3.5: the message catherd pushes to the main thread when roles finish or need attention. Pure text; the
3
+ * notifier (services/notifier.ts) gathers the facts and infra/peer-inbox.ts sends the result.
4
+ */
5
+
6
+ /** Spec §3.6: `later` waits for the receiver's turn to end, `next` drains at its next tool round. Never `now`. */
7
+ export type NoticePriority = "later" | "next";
8
+
9
+ /** One role's news: it finished (`record`), or an event while it runs (`stall`). */
10
+ export interface Notice {
11
+ kind: "finished" | "stalled";
12
+ runId: string;
13
+ runTitle: string;
14
+ dispatchId: string;
15
+ name: string;
16
+ role: string;
17
+ /** the lane it worked, when it had one: the multi-role preview names it */
18
+ lane: string | null;
19
+ rung: string;
20
+ /** the record's status, or for a limit what failover did ("limit on X; failed over to Y"), or the event */
21
+ status: string;
22
+ /** the reply's STATUS word, null without a STATUS line */
23
+ replyStatus: string | null;
24
+ secs: number;
25
+ changedOwned: number;
26
+ /** the worker's reply (finished notices), capped when the message is written */
27
+ reply: string;
28
+ priority: NoticePriority;
29
+ }
30
+
31
+ /** The body of a reply a message carries at most (spec §3.5). */
32
+ export const REPLY_CAP = 2_000;
33
+
34
+ const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
35
+
36
+ /** The first line of one role's block: it must stand alone, since the Desktop preview shows only it. */
37
+ export function noticeHeader(n: Notice): string {
38
+ const head = `catherd · ${n.runTitle} · ${n.name} ${n.role} · ${n.rung} · ${n.status}`;
39
+ if (n.kind === "stalled") return `${head} · running ${n.secs}s`;
40
+ return [
41
+ head,
42
+ n.replyStatus ? `STATUS: ${n.replyStatus}` : "no STATUS",
43
+ `${n.secs}s`,
44
+ plural(n.changedOwned, "owned file changed", "owned files changed"),
45
+ ].join(" · ");
46
+ }
47
+
48
+ /** The reply, at most `cap` characters, cut on a line boundary, saying where the rest is. */
49
+ export function capNoticeReply(reply: string, cap = REPLY_CAP): string {
50
+ const text = reply.trimEnd();
51
+ if (text.length <= cap) return text;
52
+ const cut = text.slice(0, cap);
53
+ const at = cut.lastIndexOf("\n");
54
+ return `${at > 0 ? cut.slice(0, at) : cut}\n…(cut; result(run, name) has the rest)`;
55
+ }
56
+
57
+ /**
58
+ * The envelope's own tags are neutralised in a body, in any case (a parser may not care), so a reply can never
59
+ * close the envelope early.
60
+ */
61
+ const neutral = (s: string) => s.replace(/(cross)-(session)-(message)/gi, "$1 $2 $3");
62
+
63
+ function block(n: Notice): string {
64
+ const call = `run: "${n.runId}", name: "${n.name}"`;
65
+ if (n.kind === "stalled") return `${noticeHeader(n)}\nPeek: peek(${call})`;
66
+ const reply = capNoticeReply(n.reply);
67
+ return [noticeHeader(n), ...(reply ? [reply] : []), `Record: result(${call})`].join("\n");
68
+ }
69
+
70
+ /** The preview line of a message that carries several roles: `catherd · <title> · 3 roles finished: …`. */
71
+ function previewOf(ns: Notice[]): string {
72
+ const titles = [...new Set(ns.map((n) => n.runTitle))];
73
+ const where = titles.length === 1 ? titles[0] : plural(titles.length, "run", "runs");
74
+ const what = ns.every((n) => n.kind === "finished")
75
+ ? `${ns.length} roles finished`
76
+ : `${plural(ns.length, "role", "roles")} to look at`;
77
+ const each = ns.map(
78
+ (n) => `${n.lane ?? n.name} ${n.kind === "stalled" ? "stalled" : n.status.split(";")[0]}`,
79
+ );
80
+ return `catherd · ${where} · ${what}: ${each.join(", ")}`;
81
+ }
82
+
83
+ /** The message body for one or more notices: one block per role, a preview line first when there are several. */
84
+ export function formatNotices(ns: Notice[]): string {
85
+ if (ns.length === 1) return neutral(block(ns[0] as Notice));
86
+ return neutral([previewOf(ns), ...ns.map(block)].join("\n\n"));
87
+ }
88
+
89
+ /** The message's priority: the most urgent of its notices. */
90
+ export const priorityOf = (ns: Notice[]): NoticePriority =>
91
+ ns.some((n) => n.priority === "next") ? "next" : "later";
92
+
93
+ /** The envelope catherd sends: from-name only, no `from` (the MCP server has no reply address). */
94
+ export const envelope = (body: string): string =>
95
+ `<cross-session-message from-name="catherd">\n${body}\n</cross-session-message>`;