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,86 @@
1
+ import { z } from "zod";
2
+
3
+ export const ACCESS = ["read-only", "workspace-write", "full"] as const;
4
+ export type Access = (typeof ACCESS)[number];
5
+
6
+ const RUN_STATUSES = ["ok", "failed", "limit", "cli-too-old", "timeout", "cancelled"] as const;
7
+ export type RunStatus = (typeof RUN_STATUSES)[number];
8
+
9
+ const REPLY_STATUSES = ["complete", "partial", "blocked", "refused"] as const;
10
+ export type ReplyStatus = (typeof REPLY_STATUSES)[number];
11
+
12
+ export interface Tokens {
13
+ input: number;
14
+ cached: number;
15
+ output: number;
16
+ }
17
+ export const ZERO_TOKENS: Readonly<Tokens> = Object.freeze({ input: 0, cached: 0, output: 0 });
18
+
19
+ /** Spec §7: a thread past this many input tokens is spent; its next piece starts fresh. */
20
+ export const THREAD_HEAVY_INPUT = 8_000_000;
21
+
22
+ /** How a supervised worker ended, as the supervisor records it in exit.json. */
23
+ export const EXIT_REASONS = [
24
+ "exited",
25
+ "after-final",
26
+ "idle-timeout",
27
+ "wall-timeout",
28
+ "cancelled",
29
+ "lost",
30
+ ] as const;
31
+ export type ExitReason = (typeof EXIT_REASONS)[number];
32
+ export interface ExitInfo {
33
+ code: number | null;
34
+ signal: string | null;
35
+ reason: ExitReason;
36
+ endedAt: string;
37
+ }
38
+
39
+ const TokensSchema = z.object({ input: z.number(), cached: z.number(), output: z.number() });
40
+
41
+ export const RunRecordSchema = z.looseObject({
42
+ schema: z.literal(1),
43
+ runId: z.string(),
44
+ dispatchId: z.string(),
45
+ name: z.string(),
46
+ role: z.string(),
47
+ lane: z.string().nullable(),
48
+ backend: z.string(),
49
+ rung: z.string(),
50
+ attempt: z.number().int().min(1),
51
+ failoverFrom: z.string().nullable(),
52
+ thread: z.string().nullable(),
53
+ status: z.enum(RUN_STATUSES),
54
+ startedAt: z.string(),
55
+ endedAt: z.string(),
56
+ secs: z.number(),
57
+ exitCode: z.number().nullable(),
58
+ signal: z.string().nullable(),
59
+ cliVersion: z.string().nullable(),
60
+ tokens: TokensSchema,
61
+ costUsd: z.number().nullable(),
62
+ changedOwned: z.array(z.string()),
63
+ violations: z.array(z.string()),
64
+ /** true when git failed at finalize: changedOwned and violations are then unknown, not empty */
65
+ gitUnavailable: z.boolean().optional(),
66
+ replyStatus: z.enum(REPLY_STATUSES).nullable(),
67
+ replyWhy: z.string().nullable(),
68
+ threadHeavy: z.boolean(),
69
+ access: z.enum(ACCESS),
70
+ isolated: z.boolean(),
71
+ images: z.array(z.string()),
72
+ error: z.object({ code: z.string(), message: z.string() }).nullable(),
73
+ replyPath: z.string(),
74
+ /** the Claude Code session that dispatched it, copied from admit.json (spec §3.3); absent before 1.1 */
75
+ sessionId: z.string().optional(),
76
+ });
77
+ export type RunRecord = z.infer<typeof RunRecordSchema>;
78
+
79
+ const STATUS_LINE = /^STATUS:\s*(complete|partial|blocked|refused)\s*(?:—|–|-)\s*(.*)$/;
80
+
81
+ /** The role's claim about its own work: the reply's last line, `STATUS: <s> — <why>`. */
82
+ export function parseReplyStatus(reply: string): { status: ReplyStatus | null; why: string | null } {
83
+ const last = reply.trimEnd().split("\n").at(-1)?.trim() ?? "";
84
+ const m = STATUS_LINE.exec(last);
85
+ return m ? { status: m[1] as ReplyStatus, why: (m[2] ?? "").trim() } : { status: null, why: null };
86
+ }
@@ -1,8 +1,8 @@
1
- import type { Role } from "../types.ts";
2
- import { VERSION } from "../version.ts";
1
+ import type { Access } from "./record.ts";
2
+ import type { Role } from "./roles.ts";
3
3
 
4
- const READ_ONLY = ["Write", "Edit", "NotebookEdit", "Agent"];
5
- const WRITES = ["Agent"];
4
+ // The role prompts of the native Claude subagents, ported from 0.x (spec D9). Headless backends get the
5
+ // same text from the orchestrator's briefs; only native agents carry it in their agent file.
6
6
 
7
7
  const RUN_FILES =
8
8
  "The run folder is outside the project. Read and write it only through the catherd MCP tools read_run_file and write_run_file (in the tool list as mcp__plugin_catherd_catherd__read_run_file and mcp__plugin_catherd_catherd__write_run_file), with the run id the orchestrator gave you. Paths are relative to the run folder.";
@@ -15,6 +15,8 @@ const architect = [
15
15
  "",
16
16
  `Start from the dossier. Open a project file yourself only to settle a decision the dossier leaves open, and read each file once. Bash is for inspection only. Change nothing in the project. ${RUN_FILES}`,
17
17
  "",
18
+ "When the acceptance lines include plan: <path>[, <path>…], the user already has a plan and there is no dossier. Translate it, do not design: each plan task becomes lanes, each merge request or phase a milestone with its full check. Copy the plan's decisions into plan.md and the lane files, and decide only what the plan leaves undecided.",
19
+ "",
18
20
  "Write plan.md with exactly these parts, in this order:",
19
21
  "",
20
22
  "1. Decisions D1…Dn: each one is a choice, the alternative you rejected, and one line on why. Cover only choices where two reasonable engineers would differ.",
@@ -22,20 +24,22 @@ const architect = [
22
24
  "3. Lanes inside each milestone, M1.L1…: each lane gives",
23
25
  " - the files it owns (two lanes of one milestone never share a file);",
24
26
  " - what changes, stated as behavior plus the exact signatures and data shapes it introduces;",
25
- " - its fast check: the targeted command a worker reruns while it works, seconds to a minute or two.",
27
+ " - its fast check: the command a worker reruns while it works, seconds to a minute or two: its targeted tests plus the linter, and the type check when the project has one, scoped to the packages the lane owns.",
26
28
  "",
27
29
  " Every lane of a milestone runs at the same time, so split for width: more small lanes beat one long one.",
28
30
  "4. Speed: if the full check takes more than about five minutes, name why and make speeding it up (parallel tests, one shared fixture, fewer real-time waits) a lane of the first milestone.",
29
31
  "5. Edge cases the tests must pin, one line each.",
30
32
  "6. Out of scope: what no worker may touch.",
31
33
  "",
32
- "Then write one file per lane, lanes/Mx.Ly.md. It is everything that worker needs and nothing else: the lane's section, the decisions and edge cases it relies on (copied, not referenced), and the out-of-scope lines. A worker reads its lane file, never plan.md. Its first three lines are exactly:",
34
+ "Then write one file per lane, lanes/Mx.Ly.md. It is everything that worker needs and nothing else: the lane's section, the decisions and edge cases it relies on (copied, not referenced), and the out-of-scope lines. A worker reads its lane file, never plan.md. Its first five lines are exactly:",
33
35
  "",
34
36
  " # Mx.Ly — <one line>",
35
37
  " Owns: <repo-relative paths, comma-separated>",
36
38
  " Fast check: <command>",
39
+ " Kind: repo_code|terminal|ui|prose|research",
40
+ " Difficulty: copy|build|logic|hard",
37
41
  "",
38
- "catherd reads the Owns: line to keep two running lanes off the same file, and to tell a refusal (owned files unchanged) from work.",
42
+ "catherd reads the Owns: line to keep two running lanes off the same file, and to tell a refusal (owned files unchanged) from work. It reads Kind: and Difficulty: to pick the lane's model when its router is unsure.",
39
43
  "",
40
44
  "Signatures, data shapes and test case names are yours. Function bodies are the worker's. A plan that contains the implementation turns the worker into a typist and spends the most expensive model on typing.",
41
45
  "",
@@ -49,7 +53,11 @@ const architect = [
49
53
  const verifier = [
50
54
  "You verify work you did not write. You get the acceptance lines, the check command and how to run the thing. You do not get the author's account of it, and you should not look for one.",
51
55
  "",
52
- "1. Run the check command once. Report its exit code and the failing lines.",
56
+ "1. Run the check command once. Report its exit code and the failing lines. When the check has several gate items (suites, lint, builds, a boot check):",
57
+ " - Before each item, call the catherd MCP tool gate_check (mcp__plugin_catherd_catherd__gate_check) with the run id, the milestone you verify (M1, as your brief names it), the item, its command and the repo paths it depends on. When it answers carried: true, do not run the item: report it as carried over from its commit. It also tells the orchestrator which step you are on.",
58
+ " - After an item passes, call gate_pass (mcp__plugin_catherd_catherd__gate_pass) with the same item, command and paths, and the evidence.",
59
+ " - Run independent items side by side, each heavy one wrapped in catherd lock, which queues them within the machine's slots.",
60
+ " - Build each commit's images once, and reuse them for the boot check and the acceptance suite.",
53
61
  "2. Exercise every acceptance line through the real entry point: the CLI, the HTTP route, the page. Read source only to find that entry point. When a line needs data or files, build them in a temporary directory outside the project.",
54
62
  "3. Read the diff (git diff plus untracked files) for bugs the acceptance lines miss: wrong edge behavior, dead code, leftovers.",
55
63
  "",
@@ -58,19 +66,21 @@ const verifier = [
58
66
  "Return, in this order:",
59
67
  "- VERDICT: PASS or VERDICT: FAIL on the first line.",
60
68
  "- One line per acceptance line: A<n> PASS|FAIL, the command you ran and the decisive output.",
69
+ "- One line per gate item: PASS|FAIL, or carried over from <commit>.",
61
70
  "- Bugs outside the acceptance lines: file:line, what happens, the input that triggers it.",
62
71
  "- What you could not check, and why.",
63
72
  ].join("\n");
64
73
 
65
- const worker = [
66
- "You are a worker in a catherd run. The orchestrator's message is your brief: the acceptance lines, the lane file to read, the files you own and the files you must not touch, and your fast check.",
67
- "",
68
- `Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
69
- "",
70
- `Change only the files you own. Run your fast check until it passes. Run the full suite only if the brief says so, and wrap any full build or full test suite in: bunx catherd-cli@${VERSION} lock -- <command>. Other lanes share this machine.`,
71
- "",
72
- REPLY,
73
- ].join("\n");
74
+ const worker = (version: string) =>
75
+ [
76
+ "You are a worker in a catherd run. The orchestrator's message is your brief: the acceptance lines, the lane file to read, the files you own and the files you must not touch, and your fast check.",
77
+ "",
78
+ `Read your lane file first. ${RUN_FILES} If the project has a CLAUDE.md or AGENTS.md, follow it.`,
79
+ "",
80
+ `Change only the files you own. Run your fast check until it passes. Run the full suite only if the brief says so, and wrap any full build or full test suite in: bunx catherd-cli@${version} lock -- <command>. Other lanes share this machine.`,
81
+ "",
82
+ REPLY,
83
+ ].join("\n");
74
84
 
75
85
  const reviewer = [
76
86
  "You review a milestone's diff that you did not write. The brief gives the acceptance lines and the changed files; read new files in full. Change nothing in the project.",
@@ -113,18 +123,56 @@ const writer = [
113
123
  const researcher = [
114
124
  "You answer a factual question about the code, or map it for a dossier. Change nothing in the project. Give file:line for every claim.",
115
125
  "",
116
- `A dossier lists: the files and folders involved, one line each on what they hold; the existing patterns the work should copy, by path; the build, test and run commands, with how long the full suite takes; the symbols the change will call or alter, with their signatures; the project rules (CLAUDE.md, AGENTS.md, conventions) that bind this work; and risks: shared files, generated code, slow or flaky tests. It may run to 200 lines. Write it with write_run_file to the path the brief names, and reply with that path. ${RUN_FILES}`,
126
+ `A dossier lists: the files and folders involved, one line each on what they hold; the existing patterns the work should copy, by path; the build, test and run commands, with how long the full suite takes when the docs, the CI config or a log say so (never run the suite to find out; write "unknown" instead); the lint and type-check commands, and how to scope each to one package; the symbols the change will call or alter, with their signatures; the project rules (CLAUDE.md, AGENTS.md, conventions) that bind this work; and risks: shared files, generated code, slow or flaky tests. It may run to 200 lines. Write it with write_run_file to the path the brief names, and reply with that path. ${RUN_FILES}`,
117
127
  "",
118
128
  "For a single question, reply in at most 15 lines. The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>",
119
129
  ].join("\n");
120
130
 
121
- export const ROLE_PROMPTS: Record<Role, { disallowedTools: string[]; body: string }> = {
122
- architect: { disallowedTools: READ_ONLY, body: architect },
123
- verifier: { disallowedTools: READ_ONLY, body: verifier },
124
- worker: { disallowedTools: WRITES, body: worker },
125
- reviewer: { disallowedTools: READ_ONLY, body: reviewer },
126
- "ui-reviewer": { disallowedTools: READ_ONLY, body: uiReviewer },
127
- artist: { disallowedTools: WRITES, body: artist },
128
- writer: { disallowedTools: WRITES, body: writer },
129
- researcher: { disallowedTools: READ_ONLY, body: researcher },
131
+ const BODIES: Record<Role, (version: string) => string> = {
132
+ architect: () => architect,
133
+ verifier: () => verifier,
134
+ worker,
135
+ reviewer: () => reviewer,
136
+ "ui-reviewer": () => uiReviewer,
137
+ artist: () => artist,
138
+ writer: () => writer,
139
+ researcher: () => researcher,
130
140
  };
141
+
142
+ const STATUS_LINE =
143
+ "The last line of your reply is: STATUS: complete|partial|blocked|refused — <one line why>";
144
+
145
+ /**
146
+ * Spec 1.1 §6: the reply contract `dispatch` appends to every brief it writes, the tail of the role's
147
+ * prompt: reply length and the STATUS line. The architect and the verifier keep their own reply shapes.
148
+ */
149
+ const CONTRACTS: Record<Role, string> = {
150
+ architect: `Reply briefly: the milestones, each with its lanes as Mx.Ly — one line — owned files, and the full-check command. ${STATUS_LINE}`,
151
+ verifier: `The first line of your reply is VERDICT: PASS or VERDICT: FAIL. ${STATUS_LINE}`,
152
+ worker: REPLY,
153
+ reviewer: REPLY,
154
+ "ui-reviewer": REPLY,
155
+ artist: REPLY,
156
+ writer: REPLY,
157
+ researcher: `For a single question, reply in at most 15 lines. ${STATUS_LINE}`,
158
+ };
159
+
160
+ export const replyContract = (role: Role): string => CONTRACTS[role];
161
+
162
+ /** `brief` with the role's reply contract as its last paragraph, once: a brief that already ends with it is kept. */
163
+ export function withReplyContract(role: Role, brief: string): string {
164
+ const contract = CONTRACTS[role];
165
+ const body = brief.trimEnd();
166
+ return body.endsWith(contract) ? `${body}\n` : `${body}\n\n${contract}\n`;
167
+ }
168
+
169
+ /** The role's prompt; the worker's names the catherd version whose `lock` it must use. */
170
+ export const rolePrompt = (role: Role, version: string): string => BODIES[role](version);
171
+
172
+ /**
173
+ * Spec D10 for native subagents, whose only lever is the agent file's tool list: read-only drops the
174
+ * editing tools (its Bash stays, for inspection, so enforcement is advisory); every role drops Agent,
175
+ * so a role never spawns its own subagents.
176
+ */
177
+ export const nativeDisallowedTools = (access: Access): string[] =>
178
+ access === "read-only" ? ["Write", "Edit", "NotebookEdit", "Agent"] : ["Agent"];
@@ -0,0 +1,25 @@
1
+ import type { Access } from "./record.ts";
2
+
3
+ export const ROLES = [
4
+ "architect",
5
+ "verifier",
6
+ "worker",
7
+ "reviewer",
8
+ "ui-reviewer",
9
+ "artist",
10
+ "writer",
11
+ "researcher",
12
+ ] as const;
13
+ export type Role = (typeof ROLES)[number];
14
+
15
+ /** Spec §7.1: the access mode a role runs with unless its profile says otherwise. */
16
+ export const DEFAULT_ACCESS: Record<Role, Access> = {
17
+ architect: "read-only",
18
+ reviewer: "read-only",
19
+ researcher: "read-only",
20
+ worker: "workspace-write",
21
+ writer: "workspace-write",
22
+ artist: "workspace-write",
23
+ verifier: "full",
24
+ "ui-reviewer": "full",
25
+ };
@@ -0,0 +1,126 @@
1
+ import type { Difficulty, Kind } from "./lane.ts";
2
+ import type { Role } from "./roles.ts";
3
+
4
+ export const CLIMB_REASONS = [
5
+ "check-failed-twice",
6
+ "blocker",
7
+ "same-defect",
8
+ "refused",
9
+ "blocked",
10
+ "unchanged",
11
+ ] as const;
12
+ export type ClimbReason = (typeof CLIMB_REASONS)[number];
13
+
14
+ /**
15
+ * Spec §5.4: where a lane's kind and difficulty came from. `jev-kind`: Jev was sure of the kind only, and the
16
+ * difficulty came from the lane's `Difficulty:` line, else from the role's default rung.
17
+ */
18
+ export type RouteSource = "jev" | "jev-kind" | "lane" | "default";
19
+
20
+ /** What Jev said about a lane, kept with its route for outcomes.jsonl (spec §5.6). */
21
+ export interface RouteJev {
22
+ pKind: number | null;
23
+ pA: number | null;
24
+ pB: number | null;
25
+ nouls: Record<string, number>;
26
+ }
27
+
28
+ /** One row of routes.jsonl: a lane's route, or one climb of it. The last row of a lane is its current route. */
29
+ export interface RouteRow {
30
+ at: string;
31
+ lane: string;
32
+ role: Role;
33
+ rung: string;
34
+ ladder: string[];
35
+ source: "route" | "climb";
36
+ decidedBy: RouteSource;
37
+ from: string | null;
38
+ reason: string | null;
39
+ kind: Kind | null;
40
+ difficulty: Difficulty | null;
41
+ /** route rows: the Jev question set asked (null when Jev was not asked) and its probabilities */
42
+ questionSet?: string | null;
43
+ jev?: RouteJev | null;
44
+ /** climb rows: the climb was caused by the environment, not the rung's capability (spec §5.6) */
45
+ env?: boolean;
46
+ }
47
+
48
+ export function nextRung(ladder: string[], current: string): string | null {
49
+ const i = ladder.indexOf(current);
50
+ return i >= 0 && i < ladder.length - 1 ? (ladder[i + 1] as string) : null;
51
+ }
52
+
53
+ export const currentRoute = (rows: RouteRow[], lane: string): RouteRow | null =>
54
+ rows.findLast((r) => r.lane === lane) ?? null;
55
+
56
+ /**
57
+ * The lane's route in force at `at`: its last row stamped at or before that time; null when the lane
58
+ * had no row by then (or `at` is not a time). Rows without a parseable `at` are skipped.
59
+ */
60
+ export function routeAt(rows: RouteRow[], lane: string, at: string): RouteRow | null {
61
+ const t = Date.parse(at);
62
+ if (Number.isNaN(t)) return null;
63
+ return rows.findLast((r) => r.lane === lane && Date.parse(r.at) <= t) ?? null;
64
+ }
65
+
66
+ /**
67
+ * One outcomes.jsonl row (spec §5.6): how a routed lane ended, for calibrating Jev's route rule.
68
+ * A lane can get several rows (a top-rung failure later landed, a milestone landed again):
69
+ * the last row per lane wins; read them through `latestOutcomes`.
70
+ */
71
+ export interface OutcomeRow {
72
+ at: string;
73
+ lane: string;
74
+ questionSet: string | null;
75
+ jevProbs: RouteJev | null;
76
+ source: RouteSource;
77
+ startRung: string;
78
+ finalRung: string;
79
+ climbs: { from: string; to: string; reason: string; env: boolean }[];
80
+ /** true when the lane landed, false when it ended open (a climb past its top rung) */
81
+ landed: boolean;
82
+ /** landed with no climb the rung itself caused */
83
+ start_ok: boolean;
84
+ /** the ladder index it landed on; null when it ended open (censored) */
85
+ min_ok_index: number | null;
86
+ /** some climb was the environment's fault: calibration leaves this lane out */
87
+ envCaused: boolean;
88
+ }
89
+
90
+ /** The lane's outcome from its routes.jsonl rows since its last route; null when it was never routed. */
91
+ export function laneOutcome(rows: RouteRow[], lane: string, landed: boolean, at: string): OutcomeRow | null {
92
+ const mine = rows.filter((r) => r.lane === lane);
93
+ const i = mine.findLastIndex((r) => r.source === "route");
94
+ const start = mine[i];
95
+ if (!start) return null;
96
+ // every climb row counts for start_ok and envCaused, including a no-op past the top rung (from === rung),
97
+ // which only drops out of the listed climbs
98
+ const allClimbs = mine
99
+ .slice(i + 1)
100
+ .filter((r) => r.source === "climb" && r.from !== null)
101
+ .map((r) => ({ from: r.from as string, to: r.rung, reason: r.reason ?? "", env: r.env === true }));
102
+ const climbs = allClimbs.filter((c) => c.from !== c.to);
103
+ const finalRung = mine.at(-1)?.rung ?? start.rung;
104
+ const idx = start.ladder.indexOf(finalRung);
105
+ return {
106
+ at,
107
+ lane,
108
+ questionSet: start.questionSet ?? null,
109
+ jevProbs: start.jev ?? null,
110
+ source: start.decidedBy,
111
+ startRung: start.rung,
112
+ finalRung,
113
+ climbs,
114
+ landed,
115
+ start_ok: landed && allClimbs.every((c) => c.env),
116
+ min_ok_index: landed && idx >= 0 ? idx : null,
117
+ envCaused: allClimbs.some((c) => c.env),
118
+ };
119
+ }
120
+
121
+ /** Spec §5.6: one row per lane, the last one written (last row per lane wins), in first-seen lane order. */
122
+ export function latestOutcomes(rows: OutcomeRow[]): OutcomeRow[] {
123
+ const byLane = new Map<string, OutcomeRow>();
124
+ for (const r of rows) byLane.set(r.lane, r);
125
+ return [...byLane.values()];
126
+ }
@@ -0,0 +1,21 @@
1
+ /** Spec §3.1 and D6: catherd runs on Bun 1.4.0 or newer. */
2
+ export const MIN_BUN = "1.4.0";
3
+
4
+ const parts = (v: string) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1) ?? ["0", "0", "0"]).map(Number);
5
+
6
+ export function bunTooOld(version: string): boolean {
7
+ const a = parts(version);
8
+ const b = parts(MIN_BUN);
9
+ for (let i = 0; i < 3; i++)
10
+ if ((a[i] as number) !== (b[i] as number)) return (a[i] as number) < (b[i] as number);
11
+ return false;
12
+ }
13
+
14
+ /** The two lines to print, and exit 1 on, when this Bun is too old; null when it is new enough. */
15
+ export function runtimeRefusal(version: string | undefined): string[] | null {
16
+ if (version !== undefined && !bunTooOld(version)) return null;
17
+ return [
18
+ `error E_RUNTIME_TOO_OLD: catherd needs Bun ${MIN_BUN} or newer; this is ${version ? `Bun ${version}` : "not Bun"}`,
19
+ "fix: bun upgrade (or install Bun: curl -fsSL https://bun.sh/install | bash)",
20
+ ];
21
+ }
@@ -0,0 +1,29 @@
1
+ import { replaceSecrets, scrubKeyShapes } from "./secrets.ts";
2
+
3
+ /** What to strip from a captured stream: exact secret values, and paths to replace with a stable name. */
4
+ export interface Scrub {
5
+ secrets: string[];
6
+ paths: { from: string; to: string }[];
7
+ }
8
+
9
+ const EMAIL = /[\w.%+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}/g;
10
+
11
+ const longestFirst = <T>(xs: T[], len: (x: T) => number) => [...xs].sort((a, b) => len(b) - len(a));
12
+
13
+ /**
14
+ * Spec §11.8: a capture keeps no secret and no home path. Secrets (8+ chars) and key-shaped strings
15
+ * (domain/secrets.ts) become `<redacted>`, emails `<email>`, and each path its stable name, longest
16
+ * path first so a repo inside the home directory reads `<repo>`, not `~/…`.
17
+ */
18
+ export function sanitize(text: string, s: Scrub): string {
19
+ // a captured stream is JSONL: a secret with a quote, a backslash or a control character is escaped there
20
+ const forms = s.secrets.flatMap((x) => [x, JSON.stringify(x).slice(1, -1)]);
21
+ let out = scrubKeyShapes(replaceSecrets(text, forms, "<redacted>"), "<redacted>");
22
+ out = out.replace(EMAIL, "<email>");
23
+ for (const p of longestFirst(
24
+ s.paths.filter((x) => x.from.length > 1),
25
+ (x) => x.from.length,
26
+ ))
27
+ out = out.split(p.from).join(p.to);
28
+ return out;
29
+ }
@@ -0,0 +1,57 @@
1
+ // Spec §10.4 and §11.8: the one rule for what is a secret. The log redactor (infra/log.ts), fixture
2
+ // capture (domain/sanitize.ts) and Jev's lane state (domain/jev.ts) all scrub with it; each picks its mark.
3
+
4
+ /** Env names whose values are credentials: the word may sit anywhere (OPENAI_API_KEY_2, GH_TOKEN_FILE). */
5
+ const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
6
+ /** A literal secret shorter than this is never scrubbed: it would blank ordinary words. */
7
+ export const MIN_SECRET = 8;
8
+
9
+ export const isSecretName = (name: string): boolean => SECRET_NAME.test(name);
10
+
11
+ const longestFirst = (xs: string[]): string[] => [...new Set(xs)].sort((a, b) => b.length - a.length);
12
+
13
+ /** The values of env vars whose names say they hold a credential: 8+ characters, once each, longest first. */
14
+ export function secretEnvValues(env: Record<string, string | undefined>): string[] {
15
+ return longestFirst(
16
+ Object.entries(env)
17
+ .filter(([k, v]) => v !== undefined && v.length >= MIN_SECRET && isSecretName(k))
18
+ .map(([, v]) => v as string),
19
+ );
20
+ }
21
+
22
+ /** `text` with each literal secret of 8+ characters replaced by `mark`, the longest first. */
23
+ export function replaceSecrets(text: string, secrets: string[], mark: string): string {
24
+ let out = text;
25
+ for (const s of longestFirst(secrets.filter((x) => x.length >= MIN_SECRET))) out = out.split(s).join(mark);
26
+ return out;
27
+ }
28
+
29
+ /** Strings shaped like a key wherever they appear, and the part kept before the mark ("Bearer ", a URL's user). */
30
+ const KEY_SHAPES: [RegExp, string][] = [
31
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, ""],
32
+ [/sk-ant-[\w-]{10,}/g, ""],
33
+ [/\bsk-[\w-]{16,}/g, ""],
34
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, ""],
35
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, ""],
36
+ [/\bAKIA[0-9A-Z]{16}\b/g, ""],
37
+ [/\bxox[abpr]-[A-Za-z0-9-]{10,}/g, ""],
38
+ [/\b(Bearer\s+)[\w.~+/=-]{10,}/gi, "$1"],
39
+ [/(\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+(?=@)/g, "$1"],
40
+ ];
41
+
42
+ /** `text` with every key-shaped string replaced by `mark` (which must not contain `$`). */
43
+ export function scrubKeyShapes(text: string, mark: string): string {
44
+ let out = text;
45
+ for (const [re, keep] of KEY_SHAPES) out = out.replace(re, `${keep}${mark}`);
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * `api_key = value` and the like, keeping the name. Jev's state only: on a captured stream or a log row
51
+ * it would also blank counts such as `max_tokens: 100000`.
52
+ */
53
+ const ASSIGNMENT =
54
+ /\b([A-Za-z0-9_]*(?:api[_-]?key|token|secret|password)[A-Za-z0-9_]*)\s*[:=]\s*["']?[^\s"']{6,}/gi;
55
+
56
+ export const scrubSecretAssignments = (text: string, mark: string): string =>
57
+ text.replace(ASSIGNMENT, `$1=${mark}`);
@@ -0,0 +1,136 @@
1
+ import {
2
+ type Catalog,
3
+ capableFor,
4
+ type Dim,
5
+ effortOffered,
6
+ type RungInfo,
7
+ rungInfo,
8
+ scoresOf,
9
+ } from "./catalog.ts";
10
+ import { type BillingMode, type Cost, compareCost, costOf, DEFAULT_BILLING } from "./cost.ts";
11
+ import { CatherdError } from "./errors.ts";
12
+ import { DIFFICULTIES, type Difficulty, type Kind } from "./lane.ts";
13
+ import type { Role } from "./roles.ts";
14
+
15
+ /** What routing reads from a profile for one role. */
16
+ export interface RoutingProfile {
17
+ objective: "cost" | "speed";
18
+ billing: Partial<Record<string, BillingMode>>;
19
+ role: { enabled: boolean; rungs: string[]; defaultRung?: string };
20
+ }
21
+
22
+ export interface Candidate {
23
+ rung: string;
24
+ info: RungInfo;
25
+ scores: Partial<Record<Dim, number>>;
26
+ cost: Cost;
27
+ /** median seconds over the user's own runs (≥ 5 samples), else null */
28
+ secs: number | null;
29
+ }
30
+
31
+ export interface Pick {
32
+ rung: string;
33
+ ladder: string[];
34
+ }
35
+
36
+ const byNull = (a: number | null, b: number | null) =>
37
+ a === null || b === null ? (a === null ? 1 : 0) - (b === null ? 1 : 0) : a - b;
38
+
39
+ function secsOf(c: Catalog, canonical: string, kind: Kind | null): number | null {
40
+ return c.secs[`${canonical}|${kind ?? "*"}`] ?? c.secs[`${canonical}|*`] ?? null;
41
+ }
42
+
43
+ /**
44
+ * The role's enabled rungs that the catalog can place: parseable, offered by their model, listed by
45
+ * the backend when it has a listing, capable for the role, and scored (their own or a treat-like).
46
+ * Ordered by cost (spec §5.3), or by measured speed with cost breaking ties; with fewer than 5
47
+ * samples a rung has no speed, and cost orders it, which within a model is effort order.
48
+ */
49
+ export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind | null = null): Candidate[] {
50
+ if (!p.role.enabled) return [];
51
+ const out: Candidate[] = [];
52
+ for (const rung of new Set(p.role.rungs)) {
53
+ let info: RungInfo;
54
+ try {
55
+ info = rungInfo(c, rung);
56
+ } catch {
57
+ continue;
58
+ }
59
+ if (!effortOffered(info) || info.listed === false || !capableFor(c, info, role)) continue;
60
+ const s = scoresOf(c, info.canonical);
61
+ if (!s) continue;
62
+ const mode = p.billing[info.key] ?? DEFAULT_BILLING[info.key];
63
+ out.push({
64
+ rung,
65
+ info,
66
+ scores: s.values,
67
+ cost: costOf(info.family, info.parsed.effort, mode),
68
+ secs: secsOf(c, info.canonical, kind),
69
+ });
70
+ }
71
+ const cost = (a: Candidate, b: Candidate) => compareCost(a.cost, b.cost) || byNull(a.secs, b.secs);
72
+ const speed = (a: Candidate, b: Candidate) => byNull(a.secs, b.secs) || compareCost(a.cost, b.cost);
73
+ return out.sort(p.objective === "speed" ? speed : cost);
74
+ }
75
+
76
+ export function clearsBar(c: Catalog, cand: Candidate, kind: Kind, difficulty: Difficulty): boolean {
77
+ return Object.entries(c.bars[kind][difficulty]).every(
78
+ ([dim, min]) => min === undefined || (cand.scores[dim as Dim] ?? Number.NEGATIVE_INFINITY) >= min,
79
+ );
80
+ }
81
+
82
+ function noRung(role: Role): CatherdError {
83
+ return new CatherdError("E_CONFIG_INVALID", `role "${role}" has no enabled, capable, scored rung`, {
84
+ fix: "run profile_validate, then enable a scored rung or map one with catherd catalog treat-like",
85
+ });
86
+ }
87
+
88
+ /**
89
+ * The role's default rung and every candidate above it (the whole list when it has no default), in cost
90
+ * order under either objective: speed order would put slower, not stronger, rungs above the default.
91
+ */
92
+ export function defaultLadder(c: Catalog, p: RoutingProfile, role: Role): Pick {
93
+ const all = candidates(c, { ...p, objective: "cost" }, role).map((x) => x.rung);
94
+ if (all.length === 0) throw noRung(role);
95
+ const i = Math.max(0, all.indexOf(p.role.defaultRung ?? ""));
96
+ return { rung: all[i] as string, ladder: all.slice(i) };
97
+ }
98
+
99
+ /**
100
+ * The role's default difficulty for a kind: the hardest difficulty whose bar the role's default rung clears
101
+ * (`build` when it clears none, or is no candidate: never `copy`, whose start is the cheapest rung, below
102
+ * what the `default` source would pick). A route sure of the kind but not the difficulty starts there.
103
+ */
104
+ export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role, kind: Kind): Difficulty {
105
+ const start = defaultLadder(c, p, role).rung;
106
+ const cand = candidates(c, p, role, kind).find((x) => x.rung === start);
107
+ const cleared = cand ? DIFFICULTIES.filter((d) => clearsBar(c, cand, kind, d)) : [];
108
+ return cleared.at(-1) ?? "build";
109
+ }
110
+
111
+ /**
112
+ * Under `objective: "speed"` the objective picks only the start (the fastest bar-clearing rung); the
113
+ * ladder above it is the other bar-clearing rungs at least as strong on the kind's primary dimension,
114
+ * weakest first, cost breaking ties, so a lane never climbs onto a weaker rung. Ported from 0.x.
115
+ */
116
+ function speedLadder(clearing: Candidate[], kind: Kind): Pick {
117
+ const start = clearing[0] as Candidate;
118
+ const dim: Dim = kind === "terminal" ? "terminal" : "repo_code";
119
+ const strength = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
120
+ const rest = clearing
121
+ .filter((x) => x !== start && strength(x) >= strength(start))
122
+ .sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
123
+ return { rung: start.rung, ladder: [start, ...rest].map((x) => x.rung) };
124
+ }
125
+
126
+ /** Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). */
127
+ export function select(c: Catalog, p: RoutingProfile, role: Role, kind: Kind, difficulty: Difficulty): Pick {
128
+ const all = candidates(c, p, role, kind);
129
+ if (all.length === 0) throw noRung(role);
130
+ if (all.length === 1) return { rung: all[0]?.rung as string, ladder: [all[0]?.rung as string] };
131
+ const clearing = all.filter((x) => clearsBar(c, x, kind, difficulty));
132
+ if (clearing.length === 0) return defaultLadder(c, p, role);
133
+ return p.objective === "speed"
134
+ ? speedLadder(clearing, kind)
135
+ : { rung: clearing[0]?.rung as string, ladder: clearing.map((x) => x.rung) };
136
+ }